mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-04 13:38:46 +00:00
* perf: cut serial round trips from the agent list query path
The agent list was the slowest path on first page load. Three separate
problems compounded:
- `getListAgentsHandler` chained its reads: two ACL lookups, the avatar
refresh cache probe and the viewer skill scope all resolved serially
ahead of the list query, and `attachOwnerContacts` added two more hops
after it. The four independent reads now resolve together, and the
avatar refresh runs alongside the list query instead of before it -
refreshed paths reach the response through `urlCache`, not through
whatever the list query happened to read. Serial hops per request drop
from 7 to 4 on a warm cache.
- The avatar refresh loaded the user's whole accessible agent set (up to
MAX_AVATAR_REFRESH_AGENTS) to discover which entries were S3-backed.
Scoping the query to `avatar.source` means deployments on any other
file strategy match nothing instead of walking the full set.
- `fetchAllAgentPages` walked cursor pages at the server's default size
of 100, and callers consume the flattened result, so every extra page
was a serial round trip for no benefit. It now requests the server
maximum. Measured over a 2,860 agent account: 29 requests / 1.65s
before, 3 requests / 0.29s after.
Also parallelizes the conversation file reads in `initializeAgent`. The
convo file refs and the execute_code thread walk share no inputs, and the
two code-file lookups depend only on `threadFileIds`, so the chain of six
serial reads on every turn collapses to two. This one is time to first
token the user waits through.
* perf: virtualize the model selector agent list
Opening the agents submenu with a large agent set froze the tab and could
kill it outright. With ~10k accessible agents the submenu blocked for over
15 seconds and took the heap from 96MB to 911MB. Four per-row costs were
being multiplied by the full list, which rendered unwindowed:
- `useIsActiveItem` allocated a MutationObserver per row (10,016 of them
for one dropdown). Replaced with an Ariakit store subscription, which
needs no observer at all and returns a boolean so a row only re-renders
when its own active state flips.
- `useFavorites` ran per row, opening a jotai subscription, a query
subscription and a mutation each time. Hoisted to one call per endpoint.
- Each row rescanned `endpoint.models` to recover `isGlobal`, a field the
parent had already discarded from the array it was mapping. The parent
now passes it down from a lookup map.
- The list itself is now windowed above 100 rows. Ariakit's composite only
knows about mounted rows, so arrow-keying to the window edge previously
found no next item and let focus escape the nested menu, closing it;
`handleBoundaryNavigation` scrolls the next index in, waits for it to
mount, then moves the composite onto it. Navigation inside the window is
left to Ariakit.
Open drops from >15s to 96ms, mounted rows from 10,028 to ~18, DOM nodes
from 123,346 to ~1,000, and the heap no longer grows. Verified in browser:
arrow keys track 1:1 to index 238 and back, and click selection works.
* perf: serve the model selector from the shared VIEW agent query
The model selector asked for EDIT-scoped agents whenever the marketplace
is enabled, while `useAgentsMap` and `useMentions` asked for VIEW. Since
the cache key includes the params, that was two distinct entries, so first
page load ran the paginated walk twice and held two copies of the whole
agent list in memory. Measured against a 10k agent account: 22 list handler
invocations per page load, now 11.
Collapsing the two by asking for the same permission everywhere would have
changed what the selector shows - under the marketplace the EDIT scope is
what makes it "My Agents", with discovery handled by the marketplace entry.
So the list endpoint now marks each row with `isEditable`, resolved from an
ACL read folded into the existing parallel batch (no extra serial hop), and
the selector filters the shared VIEW response instead of refetching. A
VIEW-scoped list for a user with 2861 visible / 361 editable agents returns
exactly 360 rows flagged editable, matching what the EDIT query returned.
`AgentSelect` deliberately keeps its own EDIT query: it reads `skills` and
`skills_enabled`, which `sanitizeViewerSkillScope` strips from VIEW-scoped
responses. It also only mounts when the builder panel is open, so it is not
part of the first-load cost.
The field is set unconditionally rather than omitted when false so that a
client talking to an older server sees `undefined`, keeps every agent, and
degrades to showing too many rather than none.
* fix: address review findings on the agent list at scale
Three issues from review, all confirmed against the code before fixing.
Avatar refresh no longer runs alongside the list query. `updateAgent` writes
through `findOneAndUpdate` on a `timestamps: true` schema, so refreshing an
avatar advances `updatedAt` — the field `getListAgentsByAccess` sorts and
cursors on. A write landing after the first page's snapshot moved that agent
ahead of the returned cursor, dropping it from every later page and silently
truncating the caller's flattened list. This was a regression introduced when
the two were parallelized; serializing them costs nothing on the common path,
because a cache hit returns without issuing any query, so only the
once-per-30-minutes miss pays for the ordering. The new test asserts the write
lands before the list snapshot and fails against the parallel version.
The virtualized list no longer inserts a focusable grid into the combobox.
`List` spreads its props onto `Grid`, whose defaults are `role="grid"`,
`containerRole="row"` and `tabIndex={0}`; inside Ariakit's listbox that added a
tab stop ahead of any row and put grid/row semantics between the listbox and its
options. All three are now neutralized so focus and ARIA stay with the combobox
items.
The list also resets to the top when the filter changes. `Grid` keeps its scroll
offset across prop changes and clamps an out-of-range offset to
`totalRowsHeight - height`, the end of the shorter list. Scrolling deep and then
searching landed on the tail: measured at row 626 of 667 matches, with only
those rows mounted and reachable by keyboard. Keying the list on the search
value restores row 0.
* fix: declare option position and set size for the virtualized model list
Once the model list is windowed, only the mounted slice exists in the listbox,
so a screen reader infers position and total from ~19 elements instead of the
real set — announcing "3 of 19" partway through 10,014 agents.
Model rows now carry aria-posinset and aria-setsize. The marketplace entry and
any model specs share the same numbering, because they are options in the same
listbox: declaring the values on some options while leaving others to be
inferred from the DOM would make the set internally inconsistent. Both are
omitted entirely when the list is short enough to render unwindowed, where the
DOM holds every option and the implicit values are already correct.
Verified against a 10,014 agent account: the marketplace entry reports 1 of
10015, the first models 2 and 3, and after scrolling to row 4999 the leading
mounted model reports 5001 of 10015 with 19 options in the DOM.
* 🩹 fix: Address Follow-Ups on the Agent List at Scale
Corrects residual issues in the agent-list perf work, all inside its own scope.
- Forward `idOnTheSource` through `PermissionService.findAccessibleResources`
so `getUserPrincipals` skips the user-document read. The list handler resolves
three permission sets per request and each was paying its own `User.findById`;
the auth strategies already normalize the field to a value or null.
- Gate the editable-set lookup on its own predicate instead of borrowing
`canReturnSkillConfig`. The two answer unrelated questions and only coincide
today, so redefining the skill flag would have marked every agent editable.
- Log mapping failures in the list response instead of swallowing them.
- Apply the walk page size after the caller's params in `fetchAllAgentPages`.
A caller limit only changed page size, never what the flattened walk returned,
so `defaultAgentParams`' `limit: 10` would have turned one request into 301.
- Carry `isEditable` on the agent rows the create and update mutations write
into the list cache. Mutation responses omit the field, so those rows lost it.
- Document `isEditable` as list-only, ACL-derived, and fail-open on absence.
- Restore the truthiness guard on the thread walk in `initializeAgent`. Widening
it to `!= null` made an empty `parentMessageId` issue a full-conversation read
against an anchor that can never match.
- Await `getConvoFiles` directly rather than calling `.then()` on it, restoring
tolerance for synchronous test doubles.
- Correct the avatar-refresh comment: the projection was never full documents,
and the real reason to filter is that an unfiltered budget is self-reinforcing.
Tests: both new `initialize` tests and both new backend tests are
mutation-verified; the concurrency test fails under either serialization order.
* fix: preserve ACL isEditable when merging agent mutation responses
Mutation responses omit list-only isEditable. Inferring true from write
success promoted VIEW-only rows into the editable subset for MANAGE_AGENTS
callers who can PATCH agents their ACL marks non-editable.
* fix: sort imports in agent mutations test
ESLint import-order check failed on the isEditable cache-preservation test.
* 🧷 fix: Carry isEditable Onto Duplicated Agent List Rows
`useDuplicateAgentMutation` prepended the raw duplicate response to the cached
list, and mutation responses omit the list-only `isEditable` field. The row
survived the "My Agents" filter only by failing open on `undefined`, so it would
disappear the moment a consumer read the flag strictly.
Duplicating grants the caller ownership, so the new row is editable outright;
this is the create case rather than the merge case `mergeAgentListRow` handles.
Last cache write on this path that did not carry the field.
---------
Co-authored-by: Danny Avila <danny@librechat.ai>
1644 lines
57 KiB
JavaScript
1644 lines
57 KiB
JavaScript
const { z } = require('zod');
|
|
const fs = require('fs').promises;
|
|
const { nanoid } = require('nanoid');
|
|
const { logger } = require('@librechat/data-schemas');
|
|
const {
|
|
refreshS3Url,
|
|
splitMCPToolKey,
|
|
buildServerNameAliases,
|
|
findShadowedServerNames,
|
|
agentCreateSchema,
|
|
agentUpdateSchema,
|
|
refreshListAvatars,
|
|
collectEdgeAgentIds,
|
|
replaceEdgeSourceId,
|
|
mergeDeploymentSkillIds,
|
|
mergeAgentOcrConversion,
|
|
sanitizeModelParameters,
|
|
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 that every agent referenced in `edges` exists and is viewable.
|
|
* The create path may allow its newly generated self id because that agent
|
|
* has not been inserted yet; all other missing references are invalid.
|
|
* @param {GraphEdge[]} edges
|
|
* @param {string} userId
|
|
* @param {string} userRole
|
|
* @param {Set<string>} [allowedMissingIds]
|
|
* @returns {Promise<{ missing: string[], unauthorized: string[] }>}
|
|
*/
|
|
const validateEdgeAgentReferences = async (
|
|
edges,
|
|
userId,
|
|
userRole,
|
|
allowedMissingIds = new Set(),
|
|
) => {
|
|
const { missing, unauthorized } = await classifyAgentReferences(
|
|
collectEdgeAgentIds(edges),
|
|
userId,
|
|
userRole,
|
|
);
|
|
return {
|
|
missing: missing.filter((id) => !allowedMissingIds.has(id)),
|
|
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,
|
|
resolvedServerNames,
|
|
}) => {
|
|
const filteredTools = [];
|
|
let mcpServerConfigs;
|
|
/** normalized server name -> the raw key `mcpServerConfigs` is indexed by */
|
|
let configNamesByNormalized = new Map();
|
|
let shadowedServerNames = new Set();
|
|
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;
|
|
}
|
|
/** Shared first-wins construction — authorization must resolve a
|
|
* colliding normalized key to the SAME server execution routes to,
|
|
* or a tool could be authorized against one server and executed
|
|
* against another. */
|
|
configNamesByNormalized = buildServerNameAliases(Object.keys(mcpServerConfigs));
|
|
shadowedServerNames = findShadowedServerNames(Object.keys(mcpServerConfigs));
|
|
}
|
|
|
|
/** Tool keys embed the normalized server name; the config is keyed by the raw name. */
|
|
const [, normalizedServerName] = splitMCPToolKey(
|
|
tool,
|
|
Array.from(configNamesByNormalized.keys()),
|
|
);
|
|
const serverName = configNamesByNormalized.get(normalizedServerName) ?? normalizedServerName;
|
|
if (!serverName) {
|
|
logger.warn(
|
|
`[filterAuthorizedTools] Rejected malformed MCP tool key "${tool}" for user ${userId}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (registryUnavailable && existingToolSet?.has(tool)) {
|
|
filteredTools.push(tool);
|
|
continue;
|
|
}
|
|
|
|
if (!Object.hasOwn(mcpServerConfigs, serverName)) {
|
|
logger.warn(
|
|
`[filterAuthorizedTools] Rejected MCP tool "${tool}" — server "${serverName}" not accessible to user ${userId}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
/** A shadowed server's tools (including its `mcp_all` wildcard) produce
|
|
* the SAME normalized function names as the winning server's — in-run
|
|
* dispatch could execute either. Fail closed at authorization; this map
|
|
* is the full accessible set, so DB-vs-config collisions are visible. */
|
|
if (shadowedServerNames.has(serverName)) {
|
|
logger.warn(
|
|
`[filterAuthorizedTools] Rejected MCP tool "${tool}" — server "${serverName}" is shadowed by a name collision; rename one server to use it`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
resolvedServerNames?.add(serverName);
|
|
filteredTools.push(tool);
|
|
}
|
|
|
|
return filteredTools;
|
|
};
|
|
|
|
/**
|
|
* Removes file IDs from tool resources unless they are already attached to the
|
|
* agent or owned by an allowed uploader.
|
|
* @param {object} params
|
|
* @param {object} params.tool_resources
|
|
* @param {string | object | Array<string | object>} params.ownerIds
|
|
* @param {object} [params.existingToolResources]
|
|
* @param {string} params.logPrefix
|
|
* @returns {Promise<number>} Count of removed file references.
|
|
*/
|
|
const pruneToolResourceFileIdsForAgent = async ({
|
|
tool_resources,
|
|
ownerIds,
|
|
existingToolResources,
|
|
logPrefix,
|
|
}) => {
|
|
const referencedFileIds = collectToolResourceFileIds(tool_resources);
|
|
if (referencedFileIds.length === 0) {
|
|
return 0;
|
|
}
|
|
const ownerIdSet = new Set(
|
|
(Array.isArray(ownerIds) ? ownerIds : [ownerIds])
|
|
.filter(Boolean)
|
|
.map((ownerId) => ownerId.toString()),
|
|
);
|
|
const existingFileIds = new Set(collectToolResourceFileIds(existingToolResources ?? {}));
|
|
|
|
try {
|
|
const files = await db.getFiles({ file_id: { $in: referencedFileIds } }, null, {
|
|
file_id: 1,
|
|
user: 1,
|
|
});
|
|
const allowedIds = new Set(
|
|
(files ?? [])
|
|
.filter((file) => {
|
|
if (!file.user) {
|
|
return false;
|
|
}
|
|
return existingFileIds.has(file.file_id) || ownerIdSet.has(file.user.toString());
|
|
})
|
|
.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(
|
|
sanitizeModelParameters(agentData.model_parameters),
|
|
true,
|
|
);
|
|
}
|
|
|
|
const { id: userId, role: userRole } = req.user;
|
|
agentData.id = `agent_${nanoid()}`;
|
|
agentData.edges = replaceEdgeSourceId(agentData.edges, '', agentData.id);
|
|
|
|
if (agentData.tool_resources) {
|
|
await pruneToolResourceFileIdsForAgent({
|
|
tool_resources: agentData.tool_resources,
|
|
ownerIds: userId,
|
|
logPrefix: '[/Agents]',
|
|
});
|
|
}
|
|
|
|
if (agentData.edges?.length) {
|
|
const { missing, unauthorized } = await validateEdgeAgentReferences(
|
|
agentData.edges,
|
|
userId,
|
|
userRole,
|
|
new Set([agentData.id]),
|
|
);
|
|
if (missing.length > 0) {
|
|
return res.status(400).json({
|
|
error: 'One or more agents referenced in edges 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 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.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);
|
|
/** Resolved during authorization, so persistence indexes the real server rather
|
|
* than a suffix guess - see the note on `filterAuthorizedTools`. */
|
|
const resolvedServerNames = new Set();
|
|
agentData.tools = await filterAuthorizedTools({
|
|
tools,
|
|
userId,
|
|
role: req.user.role,
|
|
user: req.user,
|
|
mcpPermissionContext,
|
|
availableTools,
|
|
configServers,
|
|
resolvedServerNames,
|
|
});
|
|
if (hasMCPTools) {
|
|
agentData.mcpServerNames = Array.from(resolvedServerNames);
|
|
}
|
|
|
|
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,
|
|
conversation_starters: agent.conversation_starters,
|
|
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(
|
|
sanitizeModelParameters(updateData.model_parameters),
|
|
true,
|
|
);
|
|
}
|
|
|
|
if (avatarField === null) {
|
|
updateData.avatar = avatarField;
|
|
}
|
|
|
|
if (updateData.edges !== undefined) {
|
|
updateData.edges = replaceEdgeSourceId(updateData.edges, '', id);
|
|
}
|
|
|
|
if (updateData.edges?.length) {
|
|
const { id: userId, role: userRole } = req.user;
|
|
const { missing, unauthorized } = await validateEdgeAgentReferences(
|
|
updateData.edges,
|
|
userId,
|
|
userRole,
|
|
);
|
|
if (missing.length > 0) {
|
|
return res.status(400).json({
|
|
error: 'One or more agents referenced in edges 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 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 pruneToolResourceFileIdsForAgent({
|
|
tool_resources: updateData.tool_resources,
|
|
ownerIds: req.user.id,
|
|
existingToolResources: existingAgent.tool_resources,
|
|
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));
|
|
/** Every MCP tool just went away, so nothing should stay indexed. */
|
|
updateData.mcpServerNames = [];
|
|
} 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;
|
|
/** The agent's MCP tools are retained verbatim here, so carry its resolved
|
|
* names across too. Left unset when the agent has none stored, so
|
|
* `updateAgent` can still derive rather than being pinned to an empty
|
|
* index that would strip agent-scoped access. */
|
|
if (existingAgent.mcpServerNames?.length) {
|
|
updateData.mcpServerNames = existingAgent.mcpServerNames;
|
|
}
|
|
}
|
|
} else if (hasToolUpdate) {
|
|
const existingToolSet = new Set(existingTools);
|
|
const newMCPTools = requestedMCPTools.filter((t) => !existingToolSet.has(t));
|
|
/** Names resolved during authorization of the newly added tools. */
|
|
const resolvedServerNames = new Set();
|
|
|
|
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,
|
|
resolvedServerNames,
|
|
});
|
|
const rejectedSet = new Set(newMCPTools.filter((t) => !approvedNew.includes(t)));
|
|
if (rejectedSet.size > 0) {
|
|
updateData.tools = updateData.tools.filter((t) => !rejectedSet.has(t));
|
|
}
|
|
}
|
|
|
|
/** Rebuild the index from the tools that survive this edit: carry a prior name
|
|
* forward only while some retained tool still resolves to it, so detaching every
|
|
* tool for a server revokes agent-scoped access to it. The agent's own persisted
|
|
* names are the candidate set, which needs neither a registry query nor a guess. */
|
|
const priorNames = existingAgent.mcpServerNames ?? [];
|
|
if (priorNames.length > 0) {
|
|
const priorNameSet = new Set(priorNames);
|
|
for (const tool of updateData.tools ?? []) {
|
|
if (typeof tool !== 'string' || !tool.includes(Constants.mcp_delimiter)) {
|
|
continue;
|
|
}
|
|
const [, retainedName] = splitMCPToolKey(tool, priorNames);
|
|
if (retainedName && priorNameSet.has(retainedName)) {
|
|
resolvedServerNames.add(retainedName);
|
|
}
|
|
}
|
|
}
|
|
/** Supplying `[]` would pin the index empty and suppress `updateAgent`'s
|
|
* derivation, so only assert it when the result is authoritative: either we
|
|
* resolved names, or no MCP tool survives and the index genuinely is empty. */
|
|
const retainsMCPTools = (updateData.tools ?? []).some(isMCPTool);
|
|
if (resolvedServerNames.size > 0 || !retainsMCPTools) {
|
|
updateData.mcpServerNames = Array.from(resolvedServerNames);
|
|
}
|
|
}
|
|
}
|
|
|
|
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, role: userRole } = 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,
|
|
});
|
|
newAgentData.edges = replaceEdgeSourceId(newAgentData.edges, id, newAgentId);
|
|
newAgentData.edges = replaceEdgeSourceId(newAgentData.edges, '', newAgentId);
|
|
|
|
if (newAgentData.edges?.length) {
|
|
const { missing, unauthorized } = await validateEdgeAgentReferences(
|
|
newAgentData.edges,
|
|
userId,
|
|
userRole,
|
|
new Set([newAgentId]),
|
|
);
|
|
if (missing.length > 0) {
|
|
return res.status(400).json({
|
|
error: 'One or more agents referenced in edges 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 edges',
|
|
agent_ids: unauthorized,
|
|
});
|
|
}
|
|
}
|
|
|
|
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);
|
|
/** The duplicate carries the source agent's `mcpServerNames`; replace it with what
|
|
* this user is actually authorized for, or the copy would grant the source's servers. */
|
|
const resolvedServerNames = new Set();
|
|
newAgentData.tools = await filterAuthorizedTools({
|
|
tools: newAgentData.tools,
|
|
userId,
|
|
role: req.user.role,
|
|
user: req.user,
|
|
mcpPermissionContext,
|
|
availableTools,
|
|
existingTools: newAgentData.tools,
|
|
configServers,
|
|
resolvedServerNames,
|
|
});
|
|
/** When the registry is unavailable, `filterAuthorizedTools` grandfathers the
|
|
* source's tools without resolving them, so carry forward the source names those
|
|
* retained tools still point at rather than blanking the index. */
|
|
const sourceNames = agent.mcpServerNames ?? [];
|
|
if (sourceNames.length > 0) {
|
|
const sourceNameSet = new Set(sourceNames);
|
|
for (const tool of newAgentData.tools ?? []) {
|
|
if (typeof tool !== 'string' || !tool.includes(Constants.mcp_delimiter)) {
|
|
continue;
|
|
}
|
|
const [, retainedName] = splitMCPToolKey(tool, sourceNames);
|
|
if (retainedName && sourceNameSet.has(retainedName)) {
|
|
resolvedServerNames.add(retainedName);
|
|
}
|
|
}
|
|
}
|
|
newAgentData.mcpServerNames = Array.from(resolvedServerNames);
|
|
}
|
|
|
|
if (newAgentData.tool_resources) {
|
|
await pruneToolResourceFileIdsForAgent({
|
|
tool_resources: newAgentData.tool_resources,
|
|
ownerIds: 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);
|
|
/**
|
|
* Derived from the same bit as `canReturnSkillConfig` but answering a different question:
|
|
* skill-config exposure versus edit-permission reporting. An EDIT-scoped request matches
|
|
* only editable agents, so it needs no second lookup to know which ones those are.
|
|
*/
|
|
const needsEditableLookup = !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 }];
|
|
}
|
|
|
|
const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
|
|
const refreshKey = `${userId}:agents_avatar_refresh`;
|
|
|
|
/**
|
|
* These reads share no inputs, so they resolve together rather than chaining round
|
|
* trips ahead of the list query. The viewer skill scope and the editable set are only
|
|
* consumed when the page is non-empty; dispatching them here trades a wasted lookup on
|
|
* the (cheap) zero-agent path for one less serial hop on every populated page.
|
|
*
|
|
* `editableIds` lets a VIEW-scoped response mark which agents the caller may also edit,
|
|
* so consumers wanting just the editable subset can filter one shared VIEW fetch rather
|
|
* than issuing a second full paginated walk under an EDIT-scoped cache key. Requests
|
|
* that already ask for EDIT get it for free: everything they match is editable.
|
|
*
|
|
* `idOnTheSource` is forwarded so `getUserPrincipals` resolves identity without reading
|
|
* the user document; the auth strategies already normalize it to a value or null. Each
|
|
* omission would cost this handler another `User.findById`, once per lookup.
|
|
*/
|
|
const { idOnTheSource } = req.user;
|
|
const [
|
|
accessibleIds,
|
|
publiclyAccessibleIds,
|
|
cachedRefreshEntry,
|
|
accessibleSkillIds,
|
|
editableIds,
|
|
] = await Promise.all([
|
|
findAccessibleResources({
|
|
userId,
|
|
role: req.user.role,
|
|
idOnTheSource,
|
|
resourceType: ResourceType.AGENT,
|
|
requiredPermissions: requiredPermission,
|
|
}),
|
|
findPubliclyAccessibleResources({
|
|
resourceType: ResourceType.AGENT,
|
|
requiredPermissions: PermissionBits.VIEW,
|
|
}),
|
|
cache.get(refreshKey),
|
|
canReturnSkillConfig
|
|
? null
|
|
: findAccessibleResources({
|
|
userId,
|
|
role: req.user.role,
|
|
idOnTheSource,
|
|
resourceType: ResourceType.SKILL,
|
|
requiredPermissions: PermissionBits.VIEW,
|
|
}),
|
|
needsEditableLookup
|
|
? findAccessibleResources({
|
|
userId,
|
|
role: req.user.role,
|
|
idOnTheSource,
|
|
resourceType: ResourceType.AGENT,
|
|
requiredPermissions: PermissionBits.EDIT,
|
|
})
|
|
: null,
|
|
]);
|
|
|
|
const isValidCachedRefresh =
|
|
cachedRefreshEntry != null &&
|
|
typeof cachedRefreshEntry === 'object' &&
|
|
cachedRefreshEntry.urlCache != null;
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* Scoped to agents that actually carry an S3 avatar so the `MAX_AVATAR_REFRESH_AGENTS`
|
|
* budget is spent on agents that can do work. Unfiltered, that budget is the most
|
|
* recently updated accessible agents regardless of avatar, and because a refresh writes
|
|
* through `updateAgent` and advances `updatedAt`, the window is self-reinforcing: an
|
|
* S3-avatar agent ranked past the budget never enters it and its presigned URL is never
|
|
* regenerated. The predicate is not indexed (`avatar` is `Mixed`), so this trades docs
|
|
* examined for that coverage.
|
|
*
|
|
* Must settle BEFORE the list query below, and is deliberately not parallelized with
|
|
* it. `updateAgent` writes through `findOneAndUpdate` on a `timestamps: true` schema,
|
|
* so refreshing an avatar advances `updatedAt`, the very field
|
|
* `getListAgentsByAccess` sorts and cursors on. A refresh landing after the first
|
|
* page's snapshot would move that agent ahead of the returned cursor, dropping it
|
|
* from every later page and silently truncating the caller's flattened list.
|
|
* Serializing costs nothing on the common path: a cache hit returns below without
|
|
* issuing any query, so only the once-per-30-minutes miss pays for the ordering.
|
|
*/
|
|
const resolveAvatarRefresh = async () => {
|
|
if (isValidCachedRefresh) {
|
|
logger.debug('[/Agents] S3 avatar refresh already checked, skipping');
|
|
return cachedRefreshEntry;
|
|
}
|
|
try {
|
|
const fullList = await db.getListAgentsByAccess({
|
|
accessibleIds,
|
|
otherParams: { 'avatar.source': FileSources.s3 },
|
|
limit: MAX_AVATAR_REFRESH_AGENTS,
|
|
after: null,
|
|
});
|
|
const { urlCache } = await refreshListAvatars({
|
|
agents: fullList?.data ?? [],
|
|
userId,
|
|
refreshS3Url,
|
|
updateAgent: db.updateAgent,
|
|
});
|
|
const refreshEntry = { urlCache };
|
|
await cache.set(refreshKey, refreshEntry, Time.THIRTY_MINUTES);
|
|
return refreshEntry;
|
|
} catch (err) {
|
|
logger.error('[/Agents] Error refreshing avatars for full list: %o', err);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const cachedRefresh = await resolveAvatarRefresh();
|
|
|
|
// 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);
|
|
}
|
|
|
|
const accessibleSkillSet = canReturnSkillConfig
|
|
? null
|
|
: new Set(mergeDeploymentSkillIds(accessibleSkillIds).map((oid) => oid.toString()));
|
|
|
|
const publicSet = new Set(publiclyAccessibleIds.map((oid) => oid.toString()));
|
|
/** Null for EDIT-scoped requests, where every matched agent is editable by definition. */
|
|
const editableSet = editableIds ? new Set(editableIds.map((oid) => oid.toString())) : null;
|
|
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;
|
|
}
|
|
agent.isEditable = editableSet == null || editableSet.has(agent?._id?.toString());
|
|
if (
|
|
urlCache &&
|
|
agent?.id &&
|
|
agent?.avatar?.source === FileSources.s3 &&
|
|
urlCache[agent.id]
|
|
) {
|
|
agent.avatar = { ...agent.avatar, filepath: urlCache[agent.id] };
|
|
}
|
|
} catch (err) {
|
|
logger.warn('[/Agents] Error mapping agent %s for list response: %o', agent?.id, err);
|
|
}
|
|
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' });
|
|
}
|
|
|
|
const revertVersion = existingAgent.versions?.[version_index];
|
|
const storedRevertEdges = Array.isArray(revertVersion?.edges) ? revertVersion.edges : [];
|
|
const revertEdges = replaceEdgeSourceId(storedRevertEdges, '', id);
|
|
const hasLegacyEdgeSource = storedRevertEdges.some((edge) =>
|
|
Array.isArray(edge.from) ? edge.from.includes('') : edge.from === '',
|
|
);
|
|
if (revertEdges.length > 0) {
|
|
const { missing, unauthorized } = await validateEdgeAgentReferences(
|
|
revertEdges,
|
|
req.user.id,
|
|
req.user.role,
|
|
);
|
|
if (missing.length > 0) {
|
|
return res.status(400).json({
|
|
error: 'One or more agents referenced in edges 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 edges',
|
|
agent_ids: unauthorized,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Permissions are enforced via route middleware (ACL EDIT)
|
|
|
|
let updatedAgent = await db.revertAgentVersion({ id }, version_index);
|
|
const revertUpdates = {};
|
|
if (
|
|
revertVersion &&
|
|
(hasLegacyEdgeSource || (!Array.isArray(revertVersion.edges) && updatedAgent.edges?.length))
|
|
) {
|
|
revertUpdates.edges = revertEdges;
|
|
}
|
|
|
|
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 pruneToolResourceFileIdsForAgent({
|
|
tool_resources: updatedAgent.tool_resources,
|
|
ownerIds: req.user.id,
|
|
existingToolResources: updatedAgent.tool_resources,
|
|
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,
|
|
};
|