diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3635eea44b..4fd34ef2f6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,7 +32,7 @@ jobs: runs-on: ubuntu-latest name: Test Packages env: - PACKAGES: '@lobechat/file-loaders @lobechat/prompts @lobechat/model-runtime @lobechat/web-crawler @lobechat/electron-server-ipc @lobechat/utils @lobechat/python-interpreter @lobechat/context-engine @lobechat/agent-runtime @lobechat/conversation-flow @lobechat/ssrf-safe-fetch @lobechat/memory-user-memory @lobechat/types @lobechat/builtin-tool-lobe-agent model-bank' + PACKAGES: '@lobechat/file-loaders @lobechat/prompts @lobechat/model-runtime @lobechat/web-crawler @lobechat/electron-server-ipc @lobechat/utils @lobechat/python-interpreter @lobechat/context-engine @lobechat/agent-runtime @lobechat/conversation-flow @lobechat/ssrf-safe-fetch @lobechat/memory-user-memory @lobechat/types @lobechat/builtin-tool-lobe-agent model-bank @lobechat/agent-gateway-client @lobechat/agent-manager-runtime @lobechat/device-gateway-client @lobechat/device-identity @lobechat/eval-dataset-parser @lobechat/eval-rubric @lobechat/fetch-sse @lobechat/heterogeneous-agents' steps: - name: Checkout diff --git a/packages/agent-manager-runtime/package.json b/packages/agent-manager-runtime/package.json index d0184afd7c..aedea6bcd1 100644 --- a/packages/agent-manager-runtime/package.json +++ b/packages/agent-manager-runtime/package.json @@ -6,6 +6,11 @@ ".": "./src/index.ts" }, "main": "./src/index.ts", + "scripts": { + "test": "vitest", + "test:coverage": "vitest --coverage --silent='passed-only'", + "test:update": "vitest -u" + }, "dependencies": { "@lobechat/const": "workspace:*", "@lobechat/prompts": "workspace:*", diff --git a/packages/agent-manager-runtime/src/AgentManagerRuntime.ts b/packages/agent-manager-runtime/src/AgentManagerRuntime.ts index b86aba40df..563573afc8 100644 --- a/packages/agent-manager-runtime/src/AgentManagerRuntime.ts +++ b/packages/agent-manager-runtime/src/AgentManagerRuntime.ts @@ -49,6 +49,7 @@ import type { InstallPluginState, MarketToolItem, SearchAgentParams, + SearchAgentSource, SearchAgentState, SearchMarketToolsParams, SearchMarketToolsState, @@ -58,6 +59,9 @@ import type { UpdatePromptState, } from './types'; +/** Max results per searchAgents call (mirrored in the tool manifests: "max: 20") */ +const MAX_SEARCH_AGENT_LIMIT = 20; + export class AgentManagerRuntime { private agentService: IAgentService; private discoverService: IDiscoverService; @@ -382,15 +386,20 @@ export class AgentManagerRuntime { async searchAgents(params: SearchAgentParams): Promise { try { const source = params.source || 'all'; - const limit = Math.min(params.limit || 10, 20); + const limit = Math.min(params.limit || 10, MAX_SEARCH_AGENT_LIMIT); + const offset = Math.max(params.offset || 0, 0); const agents: AgentSearchItem[] = []; + let userTotal = 0; + let marketTotal = 0; + // Search user's agents if (source === 'user' || source === 'all') { - const userAgents = await this.agentService.queryAgents({ - keyword: params.keyword, - limit, - }); + const [userAgents, total] = await Promise.all([ + this.agentService.queryAgents({ keyword: params.keyword, limit, offset }), + this.agentService.countAgents({ keyword: params.keyword }), + ]); + userTotal = total; agents.push( ...userAgents.map( @@ -412,13 +421,14 @@ export class AgentManagerRuntime { ); } - // Search marketplace agents + // Search marketplace agents (first page only — offset does not apply) if (source === 'market' || source === 'all') { const marketAgents = await this.discoverService.getAssistantList({ pageSize: limit, q: params.keyword, ...(params.category && { category: params.category }), }); + marketTotal = marketAgents.totalCount ?? marketAgents.items.length; agents.push( ...marketAgents.items.map((agent) => ({ @@ -433,21 +443,53 @@ export class AgentManagerRuntime { } const uniqueAgents = agents.slice(0, limit); + const totalCount = userTotal + marketTotal; - const agentList = uniqueAgents - .map((a) => `- ${a.title || 'Untitled'} (${a.id})${a.isMarket ? ' [Market]' : ''}`) - .join('\n'); + // hasMore tracks workspace agents only: marketplace results are not offset-paged + const shownUserCount = uniqueAgents.filter((a) => !a.isMarket).length; + const hasMore = offset + shownUserCount < userTotal; + + const headerBySource: Record = { + all: `Found ${userTotal} agents in your workspace and ${marketTotal} in the marketplace, showing ${uniqueAgents.length}:`, + market: `Found ${marketTotal} agents in the marketplace, showing the first ${uniqueAgents.length}:`, + user: `Found ${userTotal} agents in your workspace, showing ${offset + 1}-${offset + uniqueAgents.length}:`, + }; + + const notes: string[] = []; + if (params.limit && params.limit > MAX_SEARCH_AGENT_LIMIT) { + notes.push( + `Note: requested limit ${params.limit} exceeds the maximum of ${MAX_SEARCH_AGENT_LIMIT}, so results were capped at ${MAX_SEARCH_AGENT_LIMIT} per call.`, + ); + } + if (hasMore) { + notes.push( + `More workspace agents available: call searchAgent with offset=${offset + shownUserCount}${source === 'all' ? ` and source="user"` : ''} to get the next page.`, + ); + } + + let content: string; + if (uniqueAgents.length === 0) { + content = + totalCount === 0 + ? 'No agents found matching your search criteria.' + : `No agents at offset ${offset}; only ${totalCount} agents match. Retry with a smaller offset.`; + } else { + const agentList = uniqueAgents + .map((a) => `- ${a.title || 'Untitled'} (${a.id})${a.isMarket ? ' [Market]' : ''}`) + .join('\n'); + content = `${headerBySource[source]}\n${agentList}`; + } + if (notes.length > 0) content += `\n\n${notes.join('\n')}`; return { - content: - uniqueAgents.length > 0 - ? `Found ${uniqueAgents.length} agents:\n${agentList}` - : 'No agents found matching your search criteria.', + content, state: { agents: uniqueAgents, + hasMore, keyword: params.keyword, + offset, source, - totalCount: uniqueAgents.length, + totalCount, } as SearchAgentState, success: true, }; diff --git a/packages/agent-manager-runtime/src/__tests__/AgentManagerRuntime.test.ts b/packages/agent-manager-runtime/src/__tests__/AgentManagerRuntime.test.ts index e9d72ff8ec..269235a9e5 100644 --- a/packages/agent-manager-runtime/src/__tests__/AgentManagerRuntime.test.ts +++ b/packages/agent-manager-runtime/src/__tests__/AgentManagerRuntime.test.ts @@ -5,6 +5,7 @@ import type { IAgentService, IDiscoverService } from '../types'; // Create mock services const mockAgentService: IAgentService = { + countAgents: vi.fn(), createAgent: vi.fn(), duplicateAgent: vi.fn(), getAgentConfigById: vi.fn(), @@ -30,8 +31,10 @@ const mockAgentMeta = { vi.mock('@/store/agent', () => ({ getAgentStoreState: vi.fn(() => ({ + agentMap: { 'agent-id': mockAgentConfig }, appendStreamingSystemRole: vi.fn(), finishStreamingSystemRole: vi.fn(), + internal_dispatchAgentMap: vi.fn(), optimisticUpdateAgentConfig: vi.fn(), optimisticUpdateAgentMeta: vi.fn(), startStreamingSystemRole: vi.fn(), @@ -245,6 +248,7 @@ describe('AgentManagerRuntime', () => { backgroundColor: null, }, ] as any); + vi.mocked(mockAgentService.countAgents).mockResolvedValue(2); const result = await runtime.searchAgents({ keyword: 'test', @@ -252,12 +256,13 @@ describe('AgentManagerRuntime', () => { }); expect(result.success).toBe(true); - expect(result.content).toContain('Found 2 agents'); + expect(result.content).toContain('Found 2 agents in your workspace, showing 1-2'); expect(result.state).toMatchObject({ agents: expect.arrayContaining([ expect.objectContaining({ id: 'agent-1', isMarket: false }), expect.objectContaining({ id: 'agent-2', isMarket: false }), ]), + hasMore: false, source: 'user', totalCount: 2, }); @@ -281,11 +286,13 @@ describe('AgentManagerRuntime', () => { }); expect(result.success).toBe(true); + expect(mockAgentService.countAgents).not.toHaveBeenCalled(); expect(result.state).toMatchObject({ agents: expect.arrayContaining([ expect.objectContaining({ id: 'market-agent-1', isMarket: true }), ]), source: 'market', + totalCount: 1, }); }); @@ -299,6 +306,7 @@ describe('AgentManagerRuntime', () => { description: null, }, ] as any); + vi.mocked(mockAgentService.countAgents).mockResolvedValue(1); vi.mocked(mockDiscoverService.getAssistantList).mockResolvedValue({ items: [{ identifier: 'market-agent', title: 'Market Agent' } as any], totalCount: 1, @@ -309,16 +317,119 @@ describe('AgentManagerRuntime', () => { expect(result.success).toBe(true); expect(result.state?.source).toBe('all'); expect(result.state?.agents).toHaveLength(2); + expect(result.state?.totalCount).toBe(2); }); it('should return no agents found message', async () => { vi.mocked(mockAgentService.queryAgents).mockResolvedValue([]); + vi.mocked(mockAgentService.countAgents).mockResolvedValue(0); const result = await runtime.searchAgents({ keyword: 'nonexistent', source: 'user' }); expect(result.success).toBe(true); expect(result.content).toContain('No agents found'); }); + + it('should report the real total and a pagination hint when more agents exist', async () => { + const page = Array.from({ length: 20 }, (_, i) => ({ + id: `agent-${i}`, + title: `Agent ${i}`, + description: null, + avatar: null, + backgroundColor: null, + })); + vi.mocked(mockAgentService.queryAgents).mockResolvedValue(page as any); + vi.mocked(mockAgentService.countAgents).mockResolvedValue(137); + + const result = await runtime.searchAgents({ limit: 20, source: 'user' }); + + expect(result.success).toBe(true); + expect(result.content).toContain('Found 137 agents in your workspace, showing 1-20'); + expect(result.content).toContain('call searchAgent with offset=20'); + expect(result.state).toMatchObject({ hasMore: true, offset: 0, totalCount: 137 }); + }); + + it('should pass offset through and compute the next page hint from it', async () => { + const page = Array.from({ length: 20 }, (_, i) => ({ + id: `agent-${20 + i}`, + title: `Agent ${20 + i}`, + description: null, + avatar: null, + backgroundColor: null, + })); + vi.mocked(mockAgentService.queryAgents).mockResolvedValue(page as any); + vi.mocked(mockAgentService.countAgents).mockResolvedValue(50); + + const result = await runtime.searchAgents({ limit: 20, offset: 20, source: 'user' }); + + expect(mockAgentService.queryAgents).toHaveBeenCalledWith({ + keyword: undefined, + limit: 20, + offset: 20, + }); + expect(result.content).toContain('Found 50 agents in your workspace, showing 21-40'); + expect(result.content).toContain('call searchAgent with offset=40'); + expect(result.state).toMatchObject({ hasMore: true, offset: 20 }); + }); + + it('should note when the requested limit is capped', async () => { + vi.mocked(mockAgentService.queryAgents).mockResolvedValue([ + { + id: 'agent-1', + title: 'Agent One', + description: null, + avatar: null, + backgroundColor: null, + }, + ] as any); + vi.mocked(mockAgentService.countAgents).mockResolvedValue(1); + + const result = await runtime.searchAgents({ limit: 50, source: 'user' }); + + expect(mockAgentService.queryAgents).toHaveBeenCalledWith({ + keyword: undefined, + limit: 20, + offset: 0, + }); + expect(result.content).toContain( + 'requested limit 50 exceeds the maximum of 20, so results were capped at 20', + ); + }); + + it('should explain an out-of-range offset instead of claiming no matches', async () => { + vi.mocked(mockAgentService.queryAgents).mockResolvedValue([]); + vi.mocked(mockAgentService.countAgents).mockResolvedValue(37); + + const result = await runtime.searchAgents({ offset: 200, source: 'user' }); + + expect(result.success).toBe(true); + expect(result.content).toContain('No agents at offset 200; only 37 agents match'); + }); + + it('should fall back to item count when marketplace omits totalCount', async () => { + vi.mocked(mockDiscoverService.getAssistantList).mockResolvedValue({ + items: [ + { identifier: 'market-agent-1', title: 'Market Agent' } as any, + { identifier: 'market-agent-2', title: 'Another Agent' } as any, + ], + totalCount: undefined, + } as any); + + const result = await runtime.searchAgents({ source: 'market' }); + + expect(result.success).toBe(true); + expect(result.state?.totalCount).toBe(2); + }); + + it('should handle search failure', async () => { + vi.mocked(mockAgentService.queryAgents).mockRejectedValue(new Error('DB unavailable')); + vi.mocked(mockAgentService.countAgents).mockResolvedValue(0); + + const result = await runtime.searchAgents({ source: 'user' }); + + expect(result.success).toBe(false); + expect(result.content).toContain('Failed to search agents'); + }); }); describe('getAvailableModels', () => { diff --git a/packages/agent-manager-runtime/src/types.ts b/packages/agent-manager-runtime/src/types.ts index 79277d4c84..765356cb1c 100644 --- a/packages/agent-manager-runtime/src/types.ts +++ b/packages/agent-manager-runtime/src/types.ts @@ -8,13 +8,14 @@ import type { PartialDeep } from 'type-fest'; * Can be implemented by client-side or server-side services */ export interface IAgentService { + countAgents: (params?: { keyword?: string }) => Promise; createAgent: (params: { config: Record }) => Promise<{ agentId?: string; sessionId?: string; }>; duplicateAgent: (agentId: string, newTitle?: string) => Promise<{ agentId: string } | null>; getAgentConfigById: (agentId: string) => Promise; - queryAgents: (params: { keyword?: string; limit?: number }) => Promise< + queryAgents: (params: { keyword?: string; limit?: number; offset?: number }) => Promise< Array<{ avatar?: string | null; backgroundColor?: string | null; @@ -133,6 +134,8 @@ export interface SearchAgentParams { category?: string; keyword?: string; limit?: number; + /** Number of workspace agents to skip, for paginating beyond the per-call limit */ + offset?: number; source?: SearchAgentSource; } @@ -147,8 +150,13 @@ export interface AgentSearchItem { export interface SearchAgentState { agents: AgentSearchItem[]; + /** Whether more workspace agents exist beyond the returned page */ + hasMore?: boolean; keyword?: string; + /** The offset used for this page of workspace agents */ + offset?: number; source: SearchAgentSource; + /** Real total of matching agents across the searched sources (not just the returned page) */ totalCount: number; } diff --git a/packages/agent-manager-runtime/vitest.config.mts b/packages/agent-manager-runtime/vitest.config.mts new file mode 100644 index 0000000000..aa5d89dece --- /dev/null +++ b/packages/agent-manager-runtime/vitest.config.mts @@ -0,0 +1,17 @@ +import { resolve } from 'node:path'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // The runtime imports app stores via `@/*`; tests mock them but the + // specifiers still need to resolve against the app src directory. + alias: { + '@': resolve(__dirname, '../../src'), + }, + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'lcov', 'text-summary'], + }, + environment: 'happy-dom', + }, +}); diff --git a/packages/builtin-tool-agent-management/src/manifest.ts b/packages/builtin-tool-agent-management/src/manifest.ts index 0d2a07dba0..77c8c3152a 100644 --- a/packages/builtin-tool-agent-management/src/manifest.ts +++ b/packages/builtin-tool-agent-management/src/manifest.ts @@ -242,7 +242,7 @@ export const AgentManagementManifest: BuiltinToolManifest = { // ==================== Search ==================== { description: - "Search for agents in your workspace or the marketplace. Use 'user' source to find your own agents, 'market' for marketplace agents, or 'all' for both.", + "Search for agents in your workspace or the marketplace. Use 'user' source to find your own agents, 'market' for marketplace agents, or 'all' for both. Results are paginated: the response reports the real total, and you can page through workspace agents with 'offset'.", name: AgentManagementApiName.searchAgent, parameters: { properties: { @@ -266,6 +266,12 @@ export const AgentManagementManifest: BuiltinToolManifest = { description: 'Maximum number of results to return (default: 10, max: 20)', type: 'number', }, + offset: { + default: 0, + description: + 'Number of workspace agents to skip, for pagination (e.g. offset=20 with limit=20 returns agents 21-40). Not applied to marketplace results.', + type: 'number', + }, }, required: [], type: 'object', diff --git a/packages/builtin-tool-agent-management/src/types.ts b/packages/builtin-tool-agent-management/src/types.ts index efd2fc80ab..cc89744989 100644 --- a/packages/builtin-tool-agent-management/src/types.ts +++ b/packages/builtin-tool-agent-management/src/types.ts @@ -193,6 +193,10 @@ export interface SearchAgentParams { * Maximum number of results (default: 10) */ limit?: number; + /** + * Number of workspace agents to skip, for paginating beyond the per-call limit + */ + offset?: number; /** * Search source: 'user' (own agents), 'market' (marketplace), 'all' (both) */ @@ -231,16 +235,24 @@ export interface SearchAgentState { * List of matching agents */ agents: AgentSearchItem[]; + /** + * Whether more workspace agents exist beyond the returned page + */ + hasMore?: boolean; /** * The search keyword used */ keyword?: string; + /** + * The offset used for this page of workspace agents + */ + offset?: number; /** * The search source used */ source: SearchAgentSource; /** - * Total count of matching agents + * Real total of matching agents across the searched sources (not just the returned page) */ totalCount: number; } diff --git a/packages/database/src/models/__tests__/agent.test.ts b/packages/database/src/models/__tests__/agent.test.ts index bb12fc209d..41473c545d 100644 --- a/packages/database/src/models/__tests__/agent.test.ts +++ b/packages/database/src/models/__tests__/agent.test.ts @@ -1749,6 +1749,70 @@ describe('AgentModel', () => { }); }); + describe('countAgents', () => { + it('should count all non-virtual agents regardless of pagination', async () => { + for (let i = 1; i <= 5; i++) { + await agentModel.create({ + title: `Agent ${i}`, + virtual: false, + }); + } + await agentModel.create({ + title: 'Virtual Agent', + virtual: true, + }); + + const total = await agentModel.countAgents(); + + expect(total).toBe(5); + // count stays the full total even when queryAgents is limited + const limitedResults = await agentModel.queryAgents({ limit: 2 }); + expect(limitedResults.length).toBe(2); + }); + + it('should apply the same keyword filter as queryAgents', async () => { + await agentModel.create({ + title: 'Code Assistant', + description: 'Helps with coding', + virtual: false, + }); + await agentModel.create({ + title: 'Writer', + description: 'Helps with writing tasks', + virtual: false, + }); + await agentModel.create({ + title: 'Designer', + description: 'Helps with design code review', + virtual: false, + }); + + // matches 'Code Assistant' (title) and 'Designer' (description) + expect(await agentModel.countAgents({ keyword: 'code' })).toBe(2); + expect(await agentModel.countAgents({ keyword: 'writing' })).toBe(1); + expect(await agentModel.countAgents({ keyword: 'nonexistent' })).toBe(0); + }); + + it('should only count agents for the current user', async () => { + await agentModel.create({ title: 'User1 Agent', virtual: false }); + await agentModel2.create({ title: 'User2 Agent', virtual: false }); + + expect(await agentModel.countAgents()).toBe(1); + expect(await agentModel2.countAgents()).toBe(1); + }); + + it('should count agents with null virtual field (treat as non-virtual)', async () => { + await serverDB.insert(agents).values({ + id: 'null-virtual-agent-count', + title: 'Null Virtual Agent', + userId, + virtual: null as unknown as boolean, + }); + + expect(await agentModel.countAgents()).toBe(1); + }); + }); + describe('checkByMarketIdentifier', () => { it('should return true when agent with marketIdentifier exists', async () => { await serverDB.insert(agents).values({ diff --git a/packages/database/src/models/agent.ts b/packages/database/src/models/agent.ts index 41b4cabb32..e75569879a 100644 --- a/packages/database/src/models/agent.ts +++ b/packages/database/src/models/agent.ts @@ -101,12 +101,10 @@ export class AgentModel { }; /** - * Query non-virtual agents with optional keyword filter. - * Returns minimal agent info (id, title, description, avatar, backgroundColor). - * Excludes virtual agents (like inbox, supervisors, etc). + * Build the where condition shared by queryAgents / countAgents: + * non-virtual agents of the current user, with optional keyword filter. */ - queryAgents = async (params?: { keyword?: string; limit?: number; offset?: number }) => { - const { keyword, limit = 9999, offset = 0 } = params ?? {}; + private buildQueryAgentsWhere = (keyword?: string) => { // Include agents where virtual is false OR null (legacy data without virtual field) const baseConditions = and( eq(agents.userId, this.userId), @@ -114,12 +112,22 @@ export class AgentModel { ); // Add keyword search condition if provided - const searchCondition = keyword + return keyword ? and( baseConditions, or(ilike(agents.title, `%${keyword}%`), ilike(agents.description, `%${keyword}%`)), ) : baseConditions; + }; + + /** + * Query non-virtual agents with optional keyword filter. + * Returns minimal agent info (id, title, description, avatar, backgroundColor). + * Excludes virtual agents (like inbox, supervisors, etc). + */ + queryAgents = async (params?: { keyword?: string; limit?: number; offset?: number }) => { + const { keyword, limit = 9999, offset = 0 } = params ?? {}; + const searchCondition = this.buildQueryAgentsWhere(keyword); return this.db .select({ @@ -136,6 +144,19 @@ export class AgentModel { .offset(offset); }; + /** + * Count non-virtual agents matching the same conditions as queryAgents. + * Used to report real totals (and pagination) when queryAgents is limited. + */ + countAgents = async (params?: { keyword?: string }): Promise => { + const result = await this.db + .select({ count: count() }) + .from(agents) + .where(this.buildQueryAgentsWhere(params?.keyword)); + + return result[0]?.count ?? 0; + }; + /** * Get minimal agent info (avatar, title, backgroundColor) by IDs. * For inbox agent (slug='inbox'), falls back to LobeAI defaults when avatar/title are missing. diff --git a/packages/eval-rubric/vitest.config.mts b/packages/eval-rubric/vitest.config.mts new file mode 100644 index 0000000000..52f86e133c --- /dev/null +++ b/packages/eval-rubric/vitest.config.mts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'lcov', 'text-summary'], + }, + environment: 'node', + }, +}); diff --git a/packages/fetch-sse/vitest.config.mts b/packages/fetch-sse/vitest.config.mts new file mode 100644 index 0000000000..9766bf17f0 --- /dev/null +++ b/packages/fetch-sse/vitest.config.mts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'lcov', 'text-summary'], + }, + environment: 'happy-dom', + }, +}); diff --git a/packages/heterogeneous-agents/src/config.test.ts b/packages/heterogeneous-agents/src/config.test.ts index c3030f0ba6..01565225b2 100644 --- a/packages/heterogeneous-agents/src/config.test.ts +++ b/packages/heterogeneous-agents/src/config.test.ts @@ -26,8 +26,12 @@ describe('heterogeneous agent config', () => { it('derives display labels from the shared config source', () => { expect(HETEROGENEOUS_TYPE_LABELS).toEqual({ + 'amp': 'Amp', 'claude-code': 'Claude Code', 'codex': 'Codex', + 'hermes': 'Hermes', + 'openclaw': 'OpenClaw', + 'opencode': 'OpenCode', }); }); }); diff --git a/src/server/routers/lambda/agent.ts b/src/server/routers/lambda/agent.ts index 67bfc2ab31..ec80cda616 100644 --- a/src/server/routers/lambda/agent.ts +++ b/src/server/routers/lambda/agent.ts @@ -42,6 +42,16 @@ export const agentRouter = router({ return ctx.agentModel.checkByMarketIdentifier(input.marketIdentifier); }), + /** + * Count non-virtual agents with optional keyword filter, matching the + * conditions of queryAgents. Lets paginated callers report real totals. + */ + countAgents: agentProcedure + .input(z.object({ keyword: z.string().optional() }).optional()) + .query(async ({ input, ctx }) => { + return ctx.agentModel.countAgents(input); + }), + /** * Create a new agent with session * Returns the created agent ID and session ID diff --git a/src/server/services/toolExecution/serverRuntimes/__tests__/agentManagement.test.ts b/src/server/services/toolExecution/serverRuntimes/__tests__/agentManagement.test.ts new file mode 100644 index 0000000000..7b25872325 --- /dev/null +++ b/src/server/services/toolExecution/serverRuntimes/__tests__/agentManagement.test.ts @@ -0,0 +1,186 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { agentManagementRuntime } from '../agentManagement'; + +const { mockCountAgents, mockGetAssistantList, mockQueryAgents } = vi.hoisted(() => ({ + mockCountAgents: vi.fn(), + mockGetAssistantList: vi.fn(), + mockQueryAgents: vi.fn(), +})); + +vi.mock('@/database/models/agent', () => ({ + AgentModel: vi.fn(() => ({ + countAgents: mockCountAgents, + queryAgents: mockQueryAgents, + })), +})); + +vi.mock('@/database/models/plugin', () => ({ + PluginModel: vi.fn(() => ({})), +})); + +vi.mock('@/server/services/discover', () => ({ + DiscoverService: vi.fn(() => ({ + getAssistantList: mockGetAssistantList, + })), +})); + +const createRuntime = () => + agentManagementRuntime.factory({ + serverDB: {} as never, + toolManifestMap: {}, + userId: 'user-1', + }); + +const makeAgents = (count: number, startIndex = 0) => + Array.from({ length: count }, (_, i) => ({ + avatar: null, + backgroundColor: null, + description: null, + id: `agent-${startIndex + i}`, + title: `Agent ${startIndex + i}`, + })); + +describe('agentManagementRuntime', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('declares the agent management runtime identifier', () => { + expect(agentManagementRuntime.identifier).toBe('lobe-agent-management'); + }); + + it('throws if required server context is missing', () => { + expect(() => agentManagementRuntime.factory({ toolManifestMap: {} })).toThrow( + 'userId and serverDB are required for Agent Management execution', + ); + }); + + describe('searchAgent', () => { + it('reports the real total and a pagination hint when more agents exist', async () => { + mockQueryAgents.mockResolvedValue(makeAgents(20)); + mockCountAgents.mockResolvedValue(137); + + const runtime = createRuntime(); + const result = await runtime.searchAgent({ limit: 20, source: 'user' }); + + expect(mockQueryAgents).toHaveBeenCalledWith({ keyword: undefined, limit: 20, offset: 0 }); + expect(result.success).toBe(true); + expect(result.content).toContain('Found 137 agents in your workspace, showing 1-20'); + expect(result.content).toContain('call searchAgent with offset=20'); + expect(result.state).toMatchObject({ hasMore: true, offset: 0, totalCount: 137 }); + }); + + it('passes offset through and computes the next page hint from it', async () => { + mockQueryAgents.mockResolvedValue(makeAgents(20, 20)); + mockCountAgents.mockResolvedValue(50); + + const runtime = createRuntime(); + const result = await runtime.searchAgent({ limit: 20, offset: 20, source: 'user' }); + + expect(mockQueryAgents).toHaveBeenCalledWith({ keyword: undefined, limit: 20, offset: 20 }); + expect(result.content).toContain('Found 50 agents in your workspace, showing 21-40'); + expect(result.content).toContain('call searchAgent with offset=40'); + expect(result.state).toMatchObject({ hasMore: true, offset: 20, totalCount: 50 }); + }); + + it('notes when the requested limit is capped', async () => { + mockQueryAgents.mockResolvedValue(makeAgents(1)); + mockCountAgents.mockResolvedValue(1); + + const runtime = createRuntime(); + const result = await runtime.searchAgent({ limit: 50, source: 'user' }); + + expect(mockQueryAgents).toHaveBeenCalledWith({ keyword: undefined, limit: 20, offset: 0 }); + expect(result.content).toContain( + 'requested limit 50 exceeds the maximum of 20, so results were capped at 20', + ); + expect(result.state).toMatchObject({ hasMore: false }); + }); + + it('returns no agents found when nothing matches', async () => { + mockQueryAgents.mockResolvedValue([]); + mockCountAgents.mockResolvedValue(0); + + const runtime = createRuntime(); + const result = await runtime.searchAgent({ keyword: 'nonexistent', source: 'user' }); + + expect(result.success).toBe(true); + expect(result.content).toContain('No agents found matching your search criteria.'); + }); + + it('explains an out-of-range offset instead of claiming no matches', async () => { + mockQueryAgents.mockResolvedValue([]); + mockCountAgents.mockResolvedValue(37); + + const runtime = createRuntime(); + const result = await runtime.searchAgent({ offset: 200, source: 'user' }); + + expect(result.success).toBe(true); + expect(result.content).toContain('No agents at offset 200; only 37 agents match'); + }); + + it('searches the marketplace without counting workspace agents', async () => { + mockGetAssistantList.mockResolvedValue({ + items: [{ identifier: 'market-agent-1', title: 'Market Agent' }], + totalCount: 42, + }); + + const runtime = createRuntime(); + const result = await runtime.searchAgent({ keyword: 'market', source: 'market' }); + + expect(mockCountAgents).not.toHaveBeenCalled(); + expect(result.content).toContain('Found 42 agents in the marketplace, showing the first 1'); + expect(result.state).toMatchObject({ + agents: [expect.objectContaining({ id: 'market-agent-1', isMarket: true })], + totalCount: 42, + }); + }); + + it('combines workspace and marketplace totals for source "all"', async () => { + mockQueryAgents.mockResolvedValue(makeAgents(1)); + mockCountAgents.mockResolvedValue(30); + mockGetAssistantList.mockResolvedValue({ + items: [{ identifier: 'market-agent-1', title: 'Market Agent' }], + totalCount: 12, + }); + + const runtime = createRuntime(); + const result = await runtime.searchAgent({ keyword: 'test' }); + + expect(result.content).toContain( + 'Found 30 agents in your workspace and 12 in the marketplace, showing 2', + ); + // next-page hint should direct the model back to workspace-only pagination + expect(result.content).toContain('call searchAgent with offset=1 and source="user"'); + expect(result.state).toMatchObject({ hasMore: true, totalCount: 42 }); + }); + + it('falls back to item count when marketplace omits totalCount', async () => { + mockGetAssistantList.mockResolvedValue({ + items: [ + { identifier: 'market-agent-1', title: 'Market Agent' }, + { identifier: 'market-agent-2', title: 'Another Agent' }, + ], + totalCount: undefined, + }); + + const runtime = createRuntime(); + const result = await runtime.searchAgent({ source: 'market' }); + + expect(result.success).toBe(true); + expect(result.state).toMatchObject({ totalCount: 2 }); + }); + + it('handles search failure', async () => { + mockQueryAgents.mockRejectedValue(new Error('DB unavailable')); + mockCountAgents.mockResolvedValue(0); + + const runtime = createRuntime(); + const result = await runtime.searchAgent({ source: 'user' }); + + expect(result.success).toBe(false); + expect(result.content).toContain('Failed to search agents'); + }); + }); +}); diff --git a/src/server/services/toolExecution/serverRuntimes/agentManagement.ts b/src/server/services/toolExecution/serverRuntimes/agentManagement.ts index 10d50b6794..f183985247 100644 --- a/src/server/services/toolExecution/serverRuntimes/agentManagement.ts +++ b/src/server/services/toolExecution/serverRuntimes/agentManagement.ts @@ -23,6 +23,9 @@ const handleError = (error: unknown, message: string): ToolExecutionResult => { return { content: `${message}: ${err.message}`, success: false }; }; +/** Max results per searchAgent call (mirrored in the tool manifest: "max: 20") */ +const MAX_SEARCH_AGENT_LIMIT = 20; + export const agentManagementRuntime: ServerRuntimeRegistration = { factory: (context) => { if (!context.userId || !context.serverDB) { @@ -205,7 +208,8 @@ export const agentManagementRuntime: ServerRuntimeRegistration = { searchAgent: async (params: SearchAgentParams): Promise => { try { const source = params.source || 'all'; - const limit = Math.min(params.limit || 10, 20); + const limit = Math.min(params.limit || 10, MAX_SEARCH_AGENT_LIMIT); + const offset = Math.max(params.offset || 0, 0); const results: Array<{ avatar?: string | null; backgroundColor?: string | null; @@ -215,17 +219,26 @@ export const agentManagementRuntime: ServerRuntimeRegistration = { title?: string | null; }> = []; + let userTotal = 0; + let marketTotal = 0; + if (source === 'user' || source === 'all') { - const userAgents = await agentModel.queryAgents({ keyword: params.keyword, limit }); + const [userAgents, total] = await Promise.all([ + agentModel.queryAgents({ keyword: params.keyword, limit, offset }), + agentModel.countAgents({ keyword: params.keyword }), + ]); + userTotal = total; results.push(...userAgents.map((a) => ({ ...a, isMarket: false }))); } + // Marketplace search returns the first page only — offset does not apply if (source === 'market' || source === 'all') { const marketResult = await discoverService.getAssistantList({ pageSize: limit, q: params.keyword, ...(params.category && { category: params.category }), }); + marketTotal = marketResult.totalCount ?? marketResult.items.length; results.push( ...marketResult.items.map((a) => ({ avatar: a.avatar, @@ -239,20 +252,53 @@ export const agentManagementRuntime: ServerRuntimeRegistration = { } const sliced = results.slice(0, limit); - const list = sliced - .map((a) => `- ${a.title || 'Untitled'} (${a.id})${a.isMarket ? ' [Market]' : ''}`) - .join('\n'); + const totalCount = userTotal + marketTotal; + + // hasMore tracks workspace agents only: marketplace results are not offset-paged + const shownUserCount = sliced.filter((a) => !a.isMarket).length; + const hasMore = offset + shownUserCount < userTotal; + + const headerBySource: Record = { + all: `Found ${userTotal} agents in your workspace and ${marketTotal} in the marketplace, showing ${sliced.length}:`, + market: `Found ${marketTotal} agents in the marketplace, showing the first ${sliced.length}:`, + user: `Found ${userTotal} agents in your workspace, showing ${offset + 1}-${offset + sliced.length}:`, + }; + + const notes: string[] = []; + if (params.limit && params.limit > MAX_SEARCH_AGENT_LIMIT) { + notes.push( + `Note: requested limit ${params.limit} exceeds the maximum of ${MAX_SEARCH_AGENT_LIMIT}, so results were capped at ${MAX_SEARCH_AGENT_LIMIT} per call.`, + ); + } + if (hasMore) { + notes.push( + `More workspace agents available: call searchAgent with offset=${offset + shownUserCount}${source === 'all' ? ` and source="user"` : ''} to get the next page.`, + ); + } + + let content: string; + if (sliced.length === 0) { + content = + totalCount === 0 + ? 'No agents found matching your search criteria.' + : `No agents at offset ${offset}; only ${totalCount} agents match. Retry with a smaller offset.`; + } else { + const list = sliced + .map((a) => `- ${a.title || 'Untitled'} (${a.id})${a.isMarket ? ' [Market]' : ''}`) + .join('\n'); + content = `${headerBySource[source]}\n${list}`; + } + if (notes.length > 0) content += `\n\n${notes.join('\n')}`; return { - content: - sliced.length > 0 - ? `Found ${sliced.length} agents:\n${list}` - : 'No agents found matching your search criteria.', + content, state: { agents: sliced, + hasMore, keyword: params.keyword, + offset, source, - totalCount: sliced.length, + totalCount, }, success: true, }; diff --git a/src/services/agent.ts b/src/services/agent.ts index de937176f4..06d266fe1b 100644 --- a/src/services/agent.ts +++ b/src/services/agent.ts @@ -215,6 +215,13 @@ class AgentService { return lambdaClient.agent.queryAgents.query(params); }; + /** + * Count non-virtual agents with optional keyword filter, matching queryAgents conditions. + */ + countAgents = async (params?: { keyword?: string }) => { + return lambdaClient.agent.countAgents.query(params); + }; + /** * Pin or unpin an agent */