diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index 997ced213f..95bf5c6246 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -878,7 +878,7 @@ const deleteAgentHandler = async (req, res) => { const getListAgentsHandler = async (req, res) => { try { const userId = req.user.id; - const { category, search, limit, cursor, promoted } = req.query; + const { category, search, limit = 100, cursor, promoted } = req.query; let requiredPermission = req.query.requiredPermission; if (typeof requiredPermission === 'string') { requiredPermission = parseInt(requiredPermission, 10); diff --git a/api/server/routes/agents/actions.js b/api/server/routes/agents/actions.js index a775f67f3f..6133ee24e1 100644 --- a/api/server/routes/agents/actions.js +++ b/api/server/routes/agents/actions.js @@ -51,6 +51,7 @@ router.get('/', async (req, res) => { const agentsResponse = await db.getListAgentsByAccess({ accessibleIds: editableAgentObjectIds, + limit: null, }); const editableAgentIds = agentsResponse.data.map((agent) => agent.id); diff --git a/client/src/data-provider/Agents/queries.ts b/client/src/data-provider/Agents/queries.ts index 7387a3e1ff..efc27cd995 100644 --- a/client/src/data-provider/Agents/queries.ts +++ b/client/src/data-provider/Agents/queries.ts @@ -15,6 +15,31 @@ export const defaultAgentParams: t.AgentListParams = { limit: 10, requiredPermission: PermissionBits.EDIT, }; + +/** Walk the cursor pagination and return all pages flattened into one `AgentListResponse`. */ +async function fetchAllAgentPages(params: t.AgentListParams): Promise { + const pages: t.AgentListResponse[] = []; + let cursor: string | null | undefined = params.cursor; + do { + const page = await dataService.listAgents({ + ...params, + ...(cursor ? { cursor } : {}), + }); + pages.push(page); + cursor = page.after; + } while (cursor); + + const lastPage = pages[pages.length - 1]; + return { + object: 'list', + data: pages.flatMap((p) => p.data), + has_more: false, + after: null, + first_id: pages[0]?.first_id ?? null, + last_id: lastPage?.last_id ?? null, + }; +} + /** * Hook for getting all available tools for A */ @@ -32,7 +57,9 @@ export const useAvailableAgentToolsQuery = (): QueryObserverResult }; /** - * Hook for listing all Agents, with optional parameters provided for pagination and sorting + * Hook for listing all Agents the user has access to. Follows cursor + * pagination internally and resolves with every page concatenated. + * Cache key shape matches `allAgentViewAndEditQueryKeys` in `./mutations.ts`. */ export const useListAgentsQuery = ( params: t.AgentListParams = defaultAgentParams, @@ -44,12 +71,8 @@ export const useListAgentsQuery = ( const enabled = !!endpointsConfig?.[EModelEndpoint.agents]; return useQuery( [QueryKeys.agents, params], - () => dataService.listAgents(params), + () => fetchAllAgentPages(params), { - // Example selector to sort them by created_at - // select: (res) => { - // return res.data.sort((a, b) => a.created_at - b.created_at); - // }, staleTime: 1000 * 5, refetchOnWindowFocus: false, refetchOnReconnect: false, diff --git a/packages/data-schemas/src/methods/aclEntry.spec.ts b/packages/data-schemas/src/methods/aclEntry.spec.ts index 575f43f9db..62bf13ad9c 100644 --- a/packages/data-schemas/src/methods/aclEntry.spec.ts +++ b/packages/data-schemas/src/methods/aclEntry.spec.ts @@ -1194,6 +1194,42 @@ describe('AclEntry Model Tests', () => { expect(agentIds).toHaveLength(1); expect(agentIds[0].toString()).toBe(agentRes.toString()); }); + + test('should have a public-principal compound index available for the planner', async () => { + await AclEntry.syncIndexes(); + const indexes = await AclEntry.collection.indexes(); + const publicIndex = indexes.find( + (idx) => + idx.key?.principalType === 1 && + idx.key?.resourceType === 1 && + idx.key?.permBits === 1 && + idx.key?.resourceId === 1, + ); + expect(publicIndex).toBeDefined(); + + await methods.grantPermission( + PrincipalType.PUBLIC, + null, + ResourceType.AGENT, + new mongoose.Types.ObjectId(), + PermissionBits.VIEW, + grantedById, + ); + + const explain = (await AclEntry.find({ + principalType: PrincipalType.PUBLIC, + resourceType: ResourceType.AGENT, + permBits: { $in: [PermissionBits.VIEW] }, + }) + .hint(publicIndex!.name as string) + .explain('queryPlanner')) as unknown as { + queryPlanner?: { winningPlan?: Record }; + }; + + const planString = JSON.stringify(explain?.queryPlanner?.winningPlan ?? {}); + expect(planString).toContain('IXSCAN'); + expect(planString).not.toContain('COLLSCAN'); + }); }); describe('aggregateAclEntries', () => { diff --git a/packages/data-schemas/src/methods/agent.spec.ts b/packages/data-schemas/src/methods/agent.spec.ts index 4efb76d3ac..2ae40cf89b 100644 --- a/packages/data-schemas/src/methods/agent.spec.ts +++ b/packages/data-schemas/src/methods/agent.spec.ts @@ -3513,6 +3513,85 @@ describe('Support Contact Field', () => { expect(result.data[0].id).toBe(agentB1.id); expect(result.data[0].author).toBe(userB.toString()); }); + + test('should cap results at the default page size when limit is omitted', async () => { + const extraAgents = []; + for (let i = 0; i < 102; i++) { + const agent = await createAgent({ + id: `agent_${uuidv4().slice(0, 12)}`, + name: `Bulk Agent ${i}`, + description: `Bulk agent ${i}`, + provider: 'openai', + model: 'gpt-4', + author: userA, + }); + extraAgents.push(agent); + } + + const accessibleIds = extraAgents.map((a) => a._id) as mongoose.Types.ObjectId[]; + + const result = await getListAgentsByAccess({ + accessibleIds, + otherParams: {}, + }); + + expect(result.data).toHaveLength(100); + expect(result.has_more).toBe(true); + expect(result.after).toBeTruthy(); + }); + + test('should return all agents when limit is explicitly null', async () => { + const extraAgents = []; + for (let i = 0; i < 102; i++) { + const agent = await createAgent({ + id: `agent_${uuidv4().slice(0, 12)}`, + name: `Bulk Agent ${i}`, + description: `Bulk agent ${i}`, + provider: 'openai', + model: 'gpt-4', + author: userA, + }); + extraAgents.push(agent); + } + + const accessibleIds = extraAgents.map((a) => a._id) as mongoose.Types.ObjectId[]; + + const result = await getListAgentsByAccess({ + accessibleIds, + otherParams: {}, + limit: null, + }); + + expect(result.data).toHaveLength(102); + expect(result.has_more).toBe(false); + expect(result.after).toBeNull(); + }); + + test('should honor explicit limits above the legacy 100 cap', async () => { + const extraAgents = []; + for (let i = 0; i < 105; i++) { + const agent = await createAgent({ + id: `agent_${uuidv4().slice(0, 12)}`, + name: `Bulk Agent ${i}`, + description: `Bulk agent ${i}`, + provider: 'openai', + model: 'gpt-4', + author: userA, + }); + extraAgents.push(agent); + } + + const accessibleIds = extraAgents.map((a) => a._id) as mongoose.Types.ObjectId[]; + + const result = await getListAgentsByAccess({ + accessibleIds, + otherParams: {}, + limit: 500, + }); + + expect(result.data).toHaveLength(105); + expect(result.has_more).toBe(false); + }); }); }); diff --git a/packages/data-schemas/src/methods/agent.ts b/packages/data-schemas/src/methods/agent.ts index 37015c21c4..6f11fa720b 100644 --- a/packages/data-schemas/src/methods/agent.ts +++ b/packages/data-schemas/src/methods/agent.ts @@ -685,12 +685,13 @@ export function createAgentMethods(mongoose: typeof import('mongoose'), deps: Ag } /** - * Get agents by accessible IDs with optional cursor-based pagination. + * Get agents by accessible IDs with cursor pagination. Defaults to a 100-page + * limit (max 1000); pass `limit: null` to opt out entirely. */ async function getListAgentsByAccess({ accessibleIds = [], otherParams = {}, - limit = null, + limit = 100, after = null, includeSkillConfig = false, }: { @@ -710,7 +711,7 @@ export function createAgentMethods(mongoose: typeof import('mongoose'), deps: Ag const Agent = mongoose.models.Agent as Model; const isPaginated = limit !== null && limit !== undefined; const normalizedLimit = isPaginated - ? Math.min(Math.max(1, parseInt(String(limit)) || 20), 100) + ? Math.min(Math.max(1, parseInt(String(limit)) || 20), 1000) : null; const baseQuery: Record = { diff --git a/packages/data-schemas/src/schema/aclEntry.ts b/packages/data-schemas/src/schema/aclEntry.ts index c612ee2886..8fe6b9f4ef 100644 --- a/packages/data-schemas/src/schema/aclEntry.ts +++ b/packages/data-schemas/src/schema/aclEntry.ts @@ -79,5 +79,7 @@ aclEntrySchema.index({ }); aclEntrySchema.index({ resourceId: 1, principalType: 1, principalId: 1, tenantId: 1 }); aclEntrySchema.index({ principalId: 1, permBits: 1, resourceType: 1, tenantId: 1 }); +/** Covers `findPublicResourceIds` and the public branch of `findAccessibleResources`. */ +aclEntrySchema.index({ principalType: 1, resourceType: 1, permBits: 1, resourceId: 1 }); export default aclEntrySchema;