diff --git a/api/server/routes/agents/__tests__/management.spec.js b/api/server/routes/agents/__tests__/management.spec.js index b8328804b2..696346fb29 100644 --- a/api/server/routes/agents/__tests__/management.spec.js +++ b/api/server/routes/agents/__tests__/management.spec.js @@ -5,6 +5,12 @@ const mockMapAgentManagementError = jest.fn(() => ({ status: 404, body: { error: { code: 'not_found', message: 'Agent not found' } }, })); +const mockList = jest.fn((_req, res) => res.status(200).json({ object: 'list', data: [] })); +const mockGet = jest.fn((_req, res) => res.status(200).json({ id: 'agent-one' })); +const mockCreateAgentManagementReadHandlers = jest.fn(() => ({ + list: mockList, + get: mockGet, +})); const mockCheckBan = jest.fn((_req, _res, next) => next()); const mockUaParser = jest.fn((_req, _res, next) => next()); @@ -21,11 +27,22 @@ jest.mock('../middleware', () => ({ })); jest.mock('@librechat/api', () => ({ mapAgentManagementError: mockMapAgentManagementError, + createAgentManagementReadHandlers: mockCreateAgentManagementReadHandlers, })); jest.mock('~/server/middleware', () => ({ checkBan: mockCheckBan, uaParser: mockUaParser, })); +jest.mock('~/server/middleware/roles/capabilities', () => ({ hasCapability: jest.fn() })); +jest.mock('~/server/services/PermissionService', () => ({ + checkPermission: jest.fn(), + findAccessibleResources: jest.fn(), +})); +jest.mock('~/models', () => ({ + getRoleByName: jest.fn(), + getAgentWithVersionCount: jest.fn(), + getAgentManagementListByAccess: jest.fn(), +})); const router = require('../management'); @@ -51,7 +68,7 @@ describe('Agent Management route boundary', () => { it('allows an authenticated non-browser client into the management router', async () => { const response = await request(app) - .get('/api/agents/v1/agents/not-a-management-operation') + .post('/api/agents/v1/agents/not-a-management-operation') .set('Authorization', 'Bearer valid-token') .set('User-Agent', 'curl/8.0.0'); @@ -62,4 +79,18 @@ describe('Agent Management route boundary', () => { expect(mockMapAgentManagementError).toHaveBeenCalledWith('not_found'); expect(mockRequireAgentManagementAuth).toHaveBeenCalledTimes(1); }); + + it('dispatches authenticated list and retrieve requests to the management read handlers', async () => { + const listResponse = await request(app) + .get('/api/agents/v1/agents') + .set('Authorization', 'Bearer valid-token'); + const getResponse = await request(app) + .get('/api/agents/v1/agents/agent-one') + .set('Authorization', 'Bearer valid-token'); + + expect(listResponse.status).toBe(200); + expect(getResponse.status).toBe(200); + expect(mockList).toHaveBeenCalledTimes(1); + expect(mockGet).toHaveBeenCalledTimes(1); + }); }); diff --git a/api/server/routes/agents/management.js b/api/server/routes/agents/management.js index a1b3c2d00f..2b9e3178b5 100644 --- a/api/server/routes/agents/management.js +++ b/api/server/routes/agents/management.js @@ -1,13 +1,27 @@ const express = require('express'); -const { mapAgentManagementError } = require('@librechat/api'); +const { createAgentManagementReadHandlers, mapAgentManagementError } = require('@librechat/api'); const { checkBan } = require('~/server/middleware'); +const { hasCapability } = require('~/server/middleware/roles/capabilities'); +const { checkPermission, findAccessibleResources } = require('~/server/services/PermissionService'); +const db = require('~/models'); const { requireAgentManagementAuth } = require('./middleware'); const router = express.Router(); +const handlers = createAgentManagementReadHandlers({ + getRoleByName: db.getRoleByName, + getAgentWithVersionCount: db.getAgentWithVersionCount, + getAgentManagementListByAccess: db.getAgentManagementListByAccess, + findAccessibleResources, + checkPermission, + hasCapability, +}); router.use(requireAgentManagementAuth); router.use(checkBan); +router.get('/', handlers.list); +router.get('/:id', handlers.get); + router.use((_req, res) => { const { status, body } = mapAgentManagementError('not_found'); return res.status(status).json(body); diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 779f76de33..053f0d89c0 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -28,6 +28,7 @@ export * from './lazySubagents'; export * from './lazyHistory'; export * from './memory'; export * from './management'; +export * from './reads'; export * from './mcpIdentity'; export * from './orphans'; export * from './migration'; diff --git a/packages/api/src/agents/management.spec.ts b/packages/api/src/agents/management.spec.ts index 68a4232c17..7a939f34cd 100644 --- a/packages/api/src/agents/management.spec.ts +++ b/packages/api/src/agents/management.spec.ts @@ -167,6 +167,16 @@ describe('Agent Management contract', () => { expect(response).not.toHaveProperty('is_promoted'); }); + it('omits legacy string avatars that are not part of the management contract', () => { + const response = projectAgentManagementResponse({ + ...persistedAgent, + avatar: 'https://example.com/legacy-avatar.png', + }); + + expect(JSON.parse(JSON.stringify(response))).not.toHaveProperty('avatar'); + expect(agentManagementResponseSchema.parse(response)).toEqual(response); + }); + it('does not apply the current request admission limit to persisted subagents', () => { const subagents = { enabled: true, diff --git a/packages/api/src/agents/management.ts b/packages/api/src/agents/management.ts index 1985fcd1d0..439d761b22 100644 --- a/packages/api/src/agents/management.ts +++ b/packages/api/src/agents/management.ts @@ -28,11 +28,15 @@ export type AgentManagementResponse = Omit< updatedAt: string; }; export type AgentManagementProjectionSource = Partial< - Omit + Omit< + AgentManagementResponse, + 'id' | 'provider' | 'model' | 'version' | 'createdAt' | 'updatedAt' | 'avatar' + > > & { id?: string; provider?: string; model?: string | null; + avatar?: AgentManagementResponse['avatar'] | string; version?: number; versions?: readonly object[]; createdAt?: string | Date; @@ -225,7 +229,7 @@ export function projectAgentManagementResponse( name: source.name, description: source.description, instructions: source.instructions, - avatar: source.avatar, + avatar: typeof source.avatar === 'string' ? undefined : source.avatar, model_parameters: source.model_parameters, tools: source.tools, skills: source.skills, diff --git a/packages/api/src/agents/reads.spec.ts b/packages/api/src/agents/reads.spec.ts new file mode 100644 index 0000000000..1783103ccf --- /dev/null +++ b/packages/api/src/agents/reads.spec.ts @@ -0,0 +1,208 @@ +import { Types } from 'mongoose'; +import { SystemCapabilities } from '@librechat/data-schemas'; +import { + Permissions, + PermissionBits, + PermissionTypes, + ResourceType, +} from 'librechat-data-provider'; +import type { IRole, IUser } from '@librechat/data-schemas'; +import type { Request, Response } from 'express'; +import type { AgentManagementReadDeps } from './reads'; +import { createAgentManagementReadHandlers } from './reads'; + +jest.mock('@librechat/data-schemas', () => { + const actual = jest.requireActual('@librechat/data-schemas'); + return { + ...actual, + logger: { warn: jest.fn(), error: jest.fn() }, + }; +}); + +const tenantId = 'tenant-a'; +const user = { + id: new Types.ObjectId().toString(), + tenantId, + role: 'USER', + idOnTheSource: 'external-user', +} as IUser; +const objectId = new Types.ObjectId(); +const agent = { + _id: objectId, + id: 'agent-one', + provider: 'openAI', + model: 'gpt-5', + name: 'Agent One', + version: 2, + createdAt: new Date('2026-09-01T10:00:00.000Z'), + updatedAt: new Date('2026-09-02T10:00:00.000Z'), +}; + +function makeRequest(overrides: Partial = {}): Request { + return { user, query: {}, params: {}, ...overrides } as Request; +} + +function makeResponse(): Response { + const response = { + status: jest.fn(), + json: jest.fn(), + }; + response.status.mockReturnValue(response); + response.json.mockReturnValue(response); + return response as unknown as Response; +} + +function makeDeps(overrides: Partial = {}): AgentManagementReadDeps { + return { + getRoleByName: jest.fn().mockResolvedValue({ + permissions: { [PermissionTypes.AGENTS]: { [Permissions.USE]: true } }, + } as unknown as IRole), + getAgentWithVersionCount: jest.fn().mockResolvedValue(agent), + getAgentManagementListByAccess: jest.fn().mockResolvedValue({ + data: [agent], + has_more: false, + after: null, + }), + findAccessibleResources: jest.fn().mockResolvedValue([objectId]), + checkPermission: jest.fn().mockResolvedValue(true), + hasCapability: jest.fn().mockResolvedValue(false), + ...overrides, + }; +} + +describe('Agent Management read handlers', () => { + it('lists only ACL-discovered records in the authenticated tenant', async () => { + const deps = makeDeps(); + const response = makeResponse(); + + await createAgentManagementReadHandlers(deps).list( + makeRequest({ query: { limit: '10' } }), + response, + ); + + expect(deps.findAccessibleResources).toHaveBeenCalledWith({ + userId: user.id, + role: user.role, + idOnTheSource: user.idOnTheSource, + resourceType: ResourceType.AGENT, + requiredPermissions: PermissionBits.EDIT, + }); + expect(deps.getAgentManagementListByAccess).toHaveBeenCalledWith({ + accessibleIds: [objectId], + tenantId, + limit: 10, + after: undefined, + }); + expect(response.status).toHaveBeenCalledWith(200); + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining({ + object: 'list', + data: [expect.objectContaining({ id: 'agent-one' })], + }), + ); + }); + + it('rejects malformed cursors before ACL or persistence work', async () => { + const deps = makeDeps(); + const response = makeResponse(); + + await createAgentManagementReadHandlers(deps).list( + makeRequest({ query: { cursor: 'not-a-cursor' } }), + response, + ); + + expect(response.status).toHaveBeenCalledWith(400); + expect(deps.findAccessibleResources).not.toHaveBeenCalled(); + expect(deps.getAgentManagementListByAccess).not.toHaveBeenCalled(); + }); + + it('retrieves by public ID and authenticated tenant before applying EDIT access', async () => { + const deps = makeDeps(); + const response = makeResponse(); + + await createAgentManagementReadHandlers(deps).get( + makeRequest({ params: { id: 'agent-one' } }), + response, + ); + + expect(deps.getAgentWithVersionCount).toHaveBeenCalledWith({ id: 'agent-one', tenantId }); + expect(deps.checkPermission).toHaveBeenCalledWith({ + userId: user.id, + role: user.role, + resourceType: ResourceType.AGENT, + resourceId: objectId, + requiredPermission: PermissionBits.EDIT, + }); + expect(response.status).toHaveBeenCalledWith(200); + expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ id: 'agent-one' })); + }); + + it('returns not found when the tenant-scoped lookup cannot resolve the ID', async () => { + const deps = makeDeps({ getAgentWithVersionCount: jest.fn().mockResolvedValue(null) }); + const response = makeResponse(); + + await createAgentManagementReadHandlers(deps).get( + makeRequest({ params: { id: 'agent-in-another-tenant' } }), + response, + ); + + expect(response.status).toHaveBeenCalledWith(404); + expect(deps.checkPermission).not.toHaveBeenCalled(); + }); + + it('denies a known Agent when neither EDIT ACL nor the management capability is held', async () => { + const deps = makeDeps({ checkPermission: jest.fn().mockResolvedValue(false) }); + const response = makeResponse(); + + await createAgentManagementReadHandlers(deps).get( + makeRequest({ params: { id: 'agent-one' } }), + response, + ); + + expect(response.status).toHaveBeenCalledWith(403); + }); + + it('uses the existing manage-agents capability as the resource ACL bypass', async () => { + const deps = makeDeps({ hasCapability: jest.fn().mockResolvedValue(true) }); + const response = makeResponse(); + + await createAgentManagementReadHandlers(deps).get( + makeRequest({ params: { id: 'agent-one' } }), + response, + ); + + expect(deps.hasCapability).toHaveBeenCalledWith(user, SystemCapabilities.MANAGE_AGENTS); + expect(deps.checkPermission).not.toHaveBeenCalled(); + expect(response.status).toHaveBeenCalledWith(200); + }); + + it('lists every Agent in the tenant for the existing manage-agents capability', async () => { + const deps = makeDeps({ hasCapability: jest.fn().mockResolvedValue(true) }); + const response = makeResponse(); + + await createAgentManagementReadHandlers(deps).list(makeRequest(), response); + + expect(deps.findAccessibleResources).not.toHaveBeenCalled(); + expect(deps.getAgentManagementListByAccess).toHaveBeenCalledWith({ + accessibleIds: null, + tenantId, + limit: 20, + after: undefined, + }); + expect(response.status).toHaveBeenCalledWith(200); + }); + + it('requires the same AGENTS:USE role permission as browser reads', async () => { + const deps = makeDeps({ + getRoleByName: jest.fn().mockResolvedValue({ + permissions: { [PermissionTypes.AGENTS]: { [Permissions.USE]: false } }, + } as never), + }); + const response = makeResponse(); + + await createAgentManagementReadHandlers(deps).list(makeRequest(), response); + + expect(response.status).toHaveBeenCalledWith(403); + expect(deps.findAccessibleResources).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/agents/reads.ts b/packages/api/src/agents/reads.ts new file mode 100644 index 0000000000..737094ed7c --- /dev/null +++ b/packages/api/src/agents/reads.ts @@ -0,0 +1,188 @@ +import { logger, ResourceCapabilityMap } from '@librechat/data-schemas'; +import { + PermissionBits, + Permissions, + PermissionTypes, + ResourceType, +} from 'librechat-data-provider'; +import type { IRole, IUser, SystemCapability } from '@librechat/data-schemas'; +import type { Request, Response } from 'express'; +import type { Types } from 'mongoose'; +import type { AgentManagementProjectionSource } from './management'; +import { + agentManagementListSchema, + mapAgentManagementError, + projectAgentManagementListResponse, + projectAgentManagementResponse, +} from './management'; +import { checkAccessWithRequestCache } from '../middleware/access'; + +type AgentManagementRecord = AgentManagementProjectionSource & { _id: Types.ObjectId }; + +export interface AgentManagementReadDeps { + getRoleByName: (roleName: string, fieldsToSelect?: string | string[]) => Promise; + getAgentWithVersionCount: (search: { + id: string; + tenantId: string; + }) => Promise; + getAgentManagementListByAccess: (params: { + accessibleIds: Types.ObjectId[] | null; + tenantId: string; + limit: number; + after?: string | null; + }) => Promise<{ + data: AgentManagementRecord[]; + has_more: boolean; + after: string | null; + }>; + findAccessibleResources: (params: { + userId: string; + role?: string; + idOnTheSource?: string; + resourceType: ResourceType; + requiredPermissions: PermissionBits; + }) => Promise; + checkPermission: (params: { + userId: string; + role?: string; + resourceType: ResourceType; + resourceId: Types.ObjectId; + requiredPermission: PermissionBits; + }) => Promise; + hasCapability: (user: IUser, capability: SystemCapability) => Promise; +} + +function sendError( + res: Response, + code: Parameters[0], + error?: unknown, +) { + const mapped = mapAgentManagementError(code, error); + return res.status(mapped.status).json(mapped.body); +} + +async function canUseAgents(req: Request, user: IUser, deps: AgentManagementReadDeps) { + return await checkAccessWithRequestCache({ + req, + user, + permissionType: PermissionTypes.AGENTS, + permissions: [Permissions.USE], + getRoleByName: deps.getRoleByName, + }); +} + +async function canViewAgent( + user: IUser, + agent: AgentManagementRecord, + deps: AgentManagementReadDeps, + canManageAll: boolean, +) { + if (canManageAll) { + return true; + } + + return await deps.checkPermission({ + userId: user.id, + role: user.role, + resourceType: ResourceType.AGENT, + resourceId: agent._id, + requiredPermission: PermissionBits.EDIT, + }); +} + +async function hasManageAgentsCapability(user: IUser, deps: AgentManagementReadDeps) { + const capability = ResourceCapabilityMap[ResourceType.AGENT]; + try { + if (capability != null && (await deps.hasCapability(user, capability))) { + return true; + } + } catch (error) { + logger.warn( + `[AgentManagement] Agent capability check failed, denying bypass: ${(error as Error).message}`, + ); + } + return false; +} + +/** Typed management read handlers; the Express route supplies concrete database and ACL dependencies. */ +export function createAgentManagementReadHandlers(deps: AgentManagementReadDeps): { + list: (req: Request, res: Response) => Promise; + get: (req: Request, res: Response) => Promise; +} { + async function list(req: Request, res: Response): Promise { + const parsedQuery = agentManagementListSchema.safeParse(req.query); + if (!parsedQuery.success) { + return sendError(res, 'invalid_request', parsedQuery.error); + } + + try { + const user = req.user as IUser | undefined; + if (!user?.id || !user.tenantId) { + return sendError(res, 'permission_denied'); + } + + const [canUse, canManageAll] = await Promise.all([ + canUseAgents(req, user, deps), + hasManageAgentsCapability(user, deps), + ]); + if (!canUse) { + return sendError(res, 'permission_denied'); + } + + const accessibleIds = canManageAll + ? null + : await deps.findAccessibleResources({ + userId: user.id, + role: user.role, + idOnTheSource: user.idOnTheSource, + resourceType: ResourceType.AGENT, + requiredPermissions: PermissionBits.EDIT, + }); + const result = await deps.getAgentManagementListByAccess({ + accessibleIds, + tenantId: user.tenantId, + limit: parsedQuery.data.limit, + after: parsedQuery.data.cursor, + }); + + return res.status(200).json(projectAgentManagementListResponse(result)); + } catch (error) { + logger.error('[AgentManagement] Error listing Agents', error); + return sendError(res, 'internal_error'); + } + } + + async function get(req: Request, res: Response): Promise { + try { + const user = req.user as IUser | undefined; + if (!user?.id || !user.tenantId) { + return sendError(res, 'permission_denied'); + } + + const [canUse, canManageAll, agent] = await Promise.all([ + canUseAgents(req, user, deps), + hasManageAgentsCapability(user, deps), + deps.getAgentWithVersionCount({ + id: req.params.id, + tenantId: user.tenantId, + }), + ]); + if (!canUse) { + return sendError(res, 'permission_denied'); + } + if (!agent) { + return sendError(res, 'not_found'); + } + if (!(await canViewAgent(user, agent, deps, canManageAll))) { + return sendError(res, 'permission_denied'); + } + + return res.status(200).json(projectAgentManagementResponse(agent)); + } catch (error) { + logger.error('[AgentManagement] Error retrieving Agent', error); + return sendError(res, 'internal_error'); + } + } + + return { list, get }; +} diff --git a/packages/data-schemas/src/methods/agent.spec.ts b/packages/data-schemas/src/methods/agent.spec.ts index 017713cf24..22fa721d80 100644 --- a/packages/data-schemas/src/methods/agent.spec.ts +++ b/packages/data-schemas/src/methods/agent.spec.ts @@ -61,6 +61,7 @@ let addAgentResourceFile: AgentMethods['addAgentResourceFile']; let removeAgentResourceFiles: AgentMethods['removeAgentResourceFiles']; let removeAgentResourceFilesFromAllAgents: AgentMethods['removeAgentResourceFilesFromAllAgents']; let getListAgentsByAccess: AgentMethods['getListAgentsByAccess']; +let getAgentManagementListByAccess: AgentMethods['getAgentManagementListByAccess']; let generateActionMetadataHash: AgentMethods['generateActionMetadataHash']; const getActions = jest.fn().mockResolvedValue([]); @@ -108,6 +109,7 @@ beforeAll(async () => { removeAgentResourceFiles = methods.removeAgentResourceFiles; removeAgentResourceFilesFromAllAgents = methods.removeAgentResourceFilesFromAllAgents; getListAgentsByAccess = methods.getListAgentsByAccess; + getAgentManagementListByAccess = methods.getAgentManagementListByAccess; generateActionMetadataHash = methods.generateActionMetadataHash; await mongoose.connect(mongoUri); @@ -4733,6 +4735,153 @@ describe('Support Contact Field', () => { }); }); +describe('getAgentManagementListByAccess', () => { + beforeEach(async () => { + await Agent.deleteMany({}); + }); + + it('returns full configuration with version counts without changing updatedAt', async () => { + const tenantId = `tenant-${uuidv4()}`; + const created = await tenantStorage.run({ tenantId }, () => + createAgent({ + id: `agent_${uuidv4()}`, + name: 'Managed Agent', + instructions: 'Keep this configuration', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: ['web_search'], + }), + ); + await tenantStorage.run({ tenantId }, () => + updateAgent({ id: created.id }, { instructions: 'Updated configuration' }), + ); + const before = await Agent.findById(created._id) + .select({ updatedAt: 1 }) + .lean<{ updatedAt?: Date }>(); + const beforeUpdatedAt = before?.updatedAt; + + const result = await tenantStorage.run({ tenantId }, () => + getAgentManagementListByAccess({ + accessibleIds: [created._id], + tenantId, + limit: 20, + }), + ); + const after = await Agent.findById(created._id) + .select({ updatedAt: 1 }) + .lean<{ updatedAt?: Date }>(); + + expect(result.data).toHaveLength(1); + expect(result.data[0]).toMatchObject({ + id: created.id, + instructions: 'Updated configuration', + tools: ['web_search'], + version: 2, + }); + expect(result.data[0].versions).toBeUndefined(); + expect(after?.updatedAt).toEqual(beforeUpdatedAt); + }); + + it('enforces tenant scope even when accessible IDs contain another tenant record', async () => { + const tenantA = `tenant-a-${uuidv4()}`; + const tenantB = `tenant-b-${uuidv4()}`; + const publicId = `agent_${uuidv4()}`; + const author = new mongoose.Types.ObjectId(); + const agentA = await tenantStorage.run({ tenantId: tenantA }, () => + createAgent({ id: publicId, name: 'A', provider: 'openai', model: 'gpt-4', author }), + ); + const agentB = await tenantStorage.run({ tenantId: tenantB }, () => + createAgent({ id: publicId, name: 'B', provider: 'openai', model: 'gpt-4', author }), + ); + + const result = await tenantStorage.run({ tenantId: tenantA }, () => + getAgentManagementListByAccess({ + accessibleIds: [agentA._id, agentB._id], + tenantId: tenantA, + limit: 20, + }), + ); + + expect(result.data.map(({ name }) => name)).toEqual(['A']); + }); + + it('keeps an unrestricted capability listing inside the authenticated tenant', async () => { + const tenantA = `tenant-a-${uuidv4()}`; + const tenantB = `tenant-b-${uuidv4()}`; + const author = new mongoose.Types.ObjectId(); + await tenantStorage.run({ tenantId: tenantA }, () => + createAgent({ + id: `agent_${uuidv4()}`, + name: 'A', + provider: 'openai', + model: 'gpt-4', + author, + }), + ); + await tenantStorage.run({ tenantId: tenantB }, () => + createAgent({ + id: `agent_${uuidv4()}`, + name: 'B', + provider: 'openai', + model: 'gpt-4', + author, + }), + ); + + const result = await tenantStorage.run({ tenantId: tenantA }, () => + getAgentManagementListByAccess({ + accessibleIds: null, + tenantId: tenantA, + limit: 20, + }), + ); + + expect(result.data.map(({ name }) => name)).toEqual(['A']); + }); + + it('paginates deterministically without overlap', async () => { + const tenantId = `tenant-${uuidv4()}`; + const author = new mongoose.Types.ObjectId(); + const agents = await tenantStorage.run({ tenantId }, () => + Promise.all( + ['A', 'B', 'C'].map((name) => + createAgent({ + id: `agent_${uuidv4()}`, + name, + provider: 'openai', + model: 'gpt-4', + author, + }), + ), + ), + ); + + const first = await tenantStorage.run({ tenantId }, () => + getAgentManagementListByAccess({ + accessibleIds: agents.map(({ _id }) => _id), + tenantId, + limit: 2, + }), + ); + const second = await tenantStorage.run({ tenantId }, () => + getAgentManagementListByAccess({ + accessibleIds: agents.map(({ _id }) => _id), + tenantId, + limit: 2, + after: first.after, + }), + ); + + expect(first.data).toHaveLength(2); + expect(first.has_more).toBe(true); + expect(first.after).toBeTruthy(); + expect(second.data).toHaveLength(1); + expect(second.has_more).toBe(false); + expect(first.data.map(({ id }) => id)).not.toContain(second.data[0].id); + }); +}); + function createBasicAgent(overrides = {}) { const defaults = { id: `agent_${uuidv4()}`, diff --git a/packages/data-schemas/src/methods/agent.ts b/packages/data-schemas/src/methods/agent.ts index 8e5e138564..a398465dd2 100644 --- a/packages/data-schemas/src/methods/agent.ts +++ b/packages/data-schemas/src/methods/agent.ts @@ -532,6 +532,22 @@ export function createAgentMethods( has_more: boolean; after: string | null; }>; + getAgentManagementListByAccess: ({ + accessibleIds, + tenantId, + limit, + after, + }: { + /** `null` means the caller already passed the unrestricted management-capability check. */ + accessibleIds: Types.ObjectId[] | null; + tenantId: string; + limit: number; + after?: string | null; + }) => Promise<{ + data: Array; + has_more: boolean; + after: string | null; + }>; removeAgentResourceFiles: ({ agent_id, files, @@ -1273,6 +1289,72 @@ export function createAgentMethods( }; } + /** + * Returns the full Agent configuration required by the management response projector. + * Unlike the browser list path, this query performs no avatar refresh or persistence write. + */ + async function getAgentManagementListByAccess({ + accessibleIds, + tenantId, + limit, + after = null, + }: { + /** `null` means the caller already passed the unrestricted management-capability check. */ + accessibleIds: Types.ObjectId[] | null; + tenantId: string; + limit: number; + after?: string | null; + }): Promise<{ + data: Array; + has_more: boolean; + after: string | null; + }> { + const Agent = mongoose.models.Agent as Model; + const match: FilterQuery = { + tenantId, + ...(accessibleIds != null ? { _id: { $in: accessibleIds } } : {}), + }; + + if (after) { + const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8')) as { + updatedAt: string; + _id: string; + }; + match.$or = [ + { updatedAt: { $lt: new Date(cursor.updatedAt) } }, + { + updatedAt: new Date(cursor.updatedAt), + _id: { $gt: new mongoose.Types.ObjectId(cursor._id) }, + }, + ]; + } + + const agents = await Agent.aggregate< + IAgent & { version: number; createdAt: Date; updatedAt: Date } + >([ + { $match: match }, + { $sort: { updatedAt: -1, _id: 1 } }, + { $limit: limit + 1 }, + { $addFields: { version: { $size: { $ifNull: ['$versions', []] } } } }, + { $project: { versions: 0 } }, + ]); + + const hasMore = agents.length > limit; + const data = hasMore ? agents.slice(0, limit) : agents; + const lastAgent = data[data.length - 1]; + const nextCursor = + hasMore && lastAgent + ? Buffer.from( + JSON.stringify({ + updatedAt: lastAgent.updatedAt.toISOString(), + _id: lastAgent._id.toString(), + }), + ).toString('base64') + : null; + + return { data, has_more: hasMore, after: nextCursor }; + } + /** * Reverts an agent to a specific version in its version history. */ @@ -1396,6 +1478,7 @@ export function createAgentMethods( countPromotedAgents, addAgentResourceFile, getListAgentsByAccess, + getAgentManagementListByAccess, removeAgentResourceFiles, generateActionMetadataHash, removeAgentFromUserFavorites, diff --git a/packages/data-schemas/src/schema/agent.ts b/packages/data-schemas/src/schema/agent.ts index a22b36b9c9..4e9c701294 100644 --- a/packages/data-schemas/src/schema/agent.ts +++ b/packages/data-schemas/src/schema/agent.ts @@ -163,6 +163,7 @@ const agentSchema: Schema = new Schema( agentSchema.index({ id: 1, tenantId: 1 }, { unique: true }); agentSchema.index({ mcpServerNames: 1, tenantId: 1 }); agentSchema.index({ updatedAt: -1, _id: 1 }); +agentSchema.index({ tenantId: 1, updatedAt: -1, _id: 1 }); agentSchema.index({ 'edges.to': 1 }); export default agentSchema;