@blocks-network/mcp-server 0.1.64 → 0.1.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/index.js +28 -5
- package/dist/registry-list.d.ts +22 -0
- package/dist/registry-list.js +47 -0
- package/dist/tools.d.ts +25 -2
- package/dist/tools.js +78 -5
- package/package.json +1 -1
- package/src/index.ts +34 -4
- package/src/registry-list.ts +61 -0
- package/src/tools.ts +123 -7
- package/tests/list-agents.test.ts +102 -5
- package/tests/registry-list.test.ts +125 -1
- package/tests/search-agent.test.ts +119 -0
package/README.md
CHANGED
|
@@ -118,6 +118,7 @@ Add to your `~/.gemini/settings.json`:
|
|
|
118
118
|
| `resume_task` | Resume a paused pipe task |
|
|
119
119
|
| `retry_task` | Retry a failed task |
|
|
120
120
|
| `list_agents` | List available agents in the registry |
|
|
121
|
+
| `search_agent` | Search the registry for agents by free-text query (name, description, tags, provider, category) |
|
|
121
122
|
| `get_agent_card` | Get the full agent card for a specific agent |
|
|
122
123
|
| `get_agent_status` | Check live availability for agents (online instance count and total task count). Per-instance live activity counters (`activeTasks`, `concurrentTasksPerInstance`, `startedAt`, `totalActiveTasks`) are reserved in the response shape but currently return `0` — the backend does not yet populate them. |
|
|
123
124
|
| `connect_task` | Connect to an existing task and stream events |
|
package/dist/index.js
CHANGED
|
@@ -5,10 +5,10 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
5
5
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
6
|
import { z } from 'zod';
|
|
7
7
|
import { TaskClient, textPart, filePartFromPath, fetchCdmConfig, getAgent, BLOCKS_MAX_UPLOAD_BYTES, } from '@blocks-network/sdk';
|
|
8
|
-
import {
|
|
8
|
+
import { listAllAgentsAuthenticated } from './registry-list.js';
|
|
9
9
|
import { fetchAgentStatus, MAX_AGENT_NAMES } from './agent-status.js';
|
|
10
10
|
import { getConsumerBalance, createConsumerTopUp } from './billing.js';
|
|
11
|
-
import { sendTask, getTask, listTasks, cancelTask, pauseTask, resumeTask, retryTask, listAgents, getAgentCard, getAgentStatus, connectTask, downloadArtifact, checkBalance, requestTopup, defaultFileSize, } from './tools.js';
|
|
11
|
+
import { sendTask, getTask, listTasks, cancelTask, pauseTask, resumeTask, retryTask, listAgents, searchAgents, getAgentCard, getAgentStatus, connectTask, downloadArtifact, checkBalance, requestTopup, defaultFileSize, } from './tools.js';
|
|
12
12
|
const ALLOWED_ROOT = process.env.BLOCKS_MCP_FILE_ROOT ?? process.cwd();
|
|
13
13
|
if (!process.env.BLOCKS_API_KEY) {
|
|
14
14
|
console.error('Warning: BLOCKS_API_KEY is not set. Private agents and paid tasks will not work.');
|
|
@@ -70,7 +70,7 @@ const deps = {
|
|
|
70
70
|
getOrgId: () => process.env.BLOCKS_ORG_ID,
|
|
71
71
|
getTaskClient,
|
|
72
72
|
getAgentByName: (agentName, options) => getAgent(agentName, options),
|
|
73
|
-
listAgents:
|
|
73
|
+
listAgents: listAllAgentsAuthenticated,
|
|
74
74
|
fetchAgentStatus,
|
|
75
75
|
getConsumerBalance,
|
|
76
76
|
createConsumerTopUp,
|
|
@@ -111,14 +111,37 @@ server.tool('cancel_task', 'Cancel a running task', { taskId: z.string().describ
|
|
|
111
111
|
server.tool('pause_task', 'Pause a running pipe task. Resume later with resume_task.', { taskId: z.string().describe('The task ID to pause') }, (params) => pauseTask(params, deps));
|
|
112
112
|
server.tool('resume_task', 'Resume a paused pipe task', { taskId: z.string().describe('The task ID to resume') }, (params) => resumeTask(params, deps));
|
|
113
113
|
server.tool('retry_task', 'Retry a failed task', { taskId: z.string().describe('The task ID to retry') }, (params) => retryTask(params, deps));
|
|
114
|
-
server.tool('list_agents', 'List available agents in the Blocks Network registry. Use listing="private" with an API key to discover your private agents.', {
|
|
114
|
+
server.tool('list_agents', 'List available agents in the Blocks Network registry. By default only agents with at least one online instance are returned; set includeOffline=true to also list registered agents that are currently offline. Use listing="private" with an API key to discover your private agents.', {
|
|
115
115
|
tag: z.string().optional().describe('Filter by tag slug'),
|
|
116
116
|
listing: z
|
|
117
117
|
.enum(['public', 'private'])
|
|
118
118
|
.optional()
|
|
119
119
|
.describe('Filter by listing visibility (default: public)'),
|
|
120
|
-
limit: z
|
|
120
|
+
limit: z
|
|
121
|
+
.number()
|
|
122
|
+
.optional()
|
|
123
|
+
.describe('Max agents to fetch across all pages; omit to fetch all'),
|
|
124
|
+
includeOffline: z
|
|
125
|
+
.boolean()
|
|
126
|
+
.optional()
|
|
127
|
+
.describe('Include agents with no online instances (default: false)'),
|
|
121
128
|
}, (params) => listAgents(params, deps));
|
|
129
|
+
server.tool('search_agent', 'Search the Blocks Network registry for agents matching a free-text query. The query matches against agent name, display name, description, tags, provider, and category, and supports field qualifiers (e.g. "agentname:translate", tag:"data"), quoted phrases, and negation ("-deprecated"). By default only agents with at least one online instance are returned; set includeOffline=true to also include matching agents that are currently offline. Use listing="private" with an API key to search your private agents.', {
|
|
130
|
+
query: z.string().describe('Free-text search query'),
|
|
131
|
+
tag: z.string().optional().describe('Additionally filter by tag slug'),
|
|
132
|
+
listing: z
|
|
133
|
+
.enum(['public', 'private'])
|
|
134
|
+
.optional()
|
|
135
|
+
.describe('Filter by listing visibility (default: public)'),
|
|
136
|
+
limit: z
|
|
137
|
+
.number()
|
|
138
|
+
.optional()
|
|
139
|
+
.describe('Max matching agents to fetch across all pages; omit to fetch all'),
|
|
140
|
+
includeOffline: z
|
|
141
|
+
.boolean()
|
|
142
|
+
.optional()
|
|
143
|
+
.describe('Include agents with no online instances (default: false)'),
|
|
144
|
+
}, (params) => searchAgents(params, deps));
|
|
122
145
|
server.tool('get_agent_card', 'Get the full agent card for a specific agent', { agentName: z.string().describe('Agent name to look up') }, (params) => getAgentCard(params, deps));
|
|
123
146
|
server.tool('get_agent_status', 'Check live availability for one or more agents: how many instances are online, total task count for the agent, and SDK/CLI versions per instance. (Per-instance live activity counters such as `activeTasks`, `concurrentTasksPerInstance`, `startedAt`, and `totalActiveTasks` are reserved in the response shape but not yet populated by the backend — they currently return 0.)', {
|
|
124
147
|
agentNames: z
|
package/dist/registry-list.d.ts
CHANGED
|
@@ -19,13 +19,35 @@ export interface AgentListEntry {
|
|
|
19
19
|
export interface ListAgentsOptions {
|
|
20
20
|
baseUrl: string;
|
|
21
21
|
apiKey?: string;
|
|
22
|
+
/** Free-text search query (`q`); matches agent name, description, tags, etc. */
|
|
23
|
+
q?: string;
|
|
22
24
|
tag?: string;
|
|
23
25
|
listing?: 'public' | 'private';
|
|
26
|
+
/** Page size for a single request. The backend caps this at 100. */
|
|
24
27
|
limit?: number;
|
|
28
|
+
/** Opaque pagination cursor returned as `next` by a previous request. */
|
|
29
|
+
cursor?: string;
|
|
25
30
|
fetchImpl?: typeof fetch;
|
|
26
31
|
}
|
|
27
32
|
export interface ListAgentsResult {
|
|
28
33
|
agents: AgentListEntry[];
|
|
29
34
|
totalCount?: number;
|
|
35
|
+
/** Opaque cursor for the next page, or null/absent when there are no more. */
|
|
36
|
+
next?: string | null;
|
|
37
|
+
/** Total agents with at least one online instance (ignores cursor). */
|
|
38
|
+
totalOnlineCount?: number;
|
|
30
39
|
}
|
|
40
|
+
/** Backend maximum page size for GET /api/v1/registry/agents. */
|
|
41
|
+
export declare const REGISTRY_PAGE_SIZE = 100;
|
|
31
42
|
export declare function listAgentsAuthenticated(opts: ListAgentsOptions): Promise<ListAgentsResult>;
|
|
43
|
+
/**
|
|
44
|
+
* Fetch every agent by following the `next` cursor until it's null/absent.
|
|
45
|
+
*
|
|
46
|
+
* Uses the backend's maximum page size (100) to minimise round-trips. The
|
|
47
|
+
* optional `maxAgents` cap bounds the total number of agents collected and
|
|
48
|
+
* shrinks the final page request so we never over-fetch. The latest
|
|
49
|
+
* `totalCount`/`totalOnlineCount` seen are carried forward to the result.
|
|
50
|
+
*/
|
|
51
|
+
export declare function listAllAgentsAuthenticated(opts: ListAgentsOptions & {
|
|
52
|
+
maxAgents?: number;
|
|
53
|
+
}): Promise<ListAgentsResult>;
|
package/dist/registry-list.js
CHANGED
|
@@ -6,8 +6,14 @@
|
|
|
6
6
|
* optional Bearer API key for private/owned listings.
|
|
7
7
|
*/
|
|
8
8
|
import { PROTOCOL_VERSION_HEADER, CURRENT_PROTOCOL_VERSION, } from './protocol-headers.js';
|
|
9
|
+
/** Backend maximum page size for GET /api/v1/registry/agents. */
|
|
10
|
+
export const REGISTRY_PAGE_SIZE = 100;
|
|
11
|
+
/** Safety backstop so a misbehaving (never-null) cursor can't loop forever. */
|
|
12
|
+
const MAX_PAGES = 1000;
|
|
9
13
|
export async function listAgentsAuthenticated(opts) {
|
|
10
14
|
const params = new URLSearchParams({ include: 'full' });
|
|
15
|
+
if (opts.q)
|
|
16
|
+
params.set('q', opts.q);
|
|
11
17
|
if (opts.tag)
|
|
12
18
|
params.set('tag', opts.tag);
|
|
13
19
|
if (opts.listing) {
|
|
@@ -17,6 +23,8 @@ export async function listAgentsAuthenticated(opts) {
|
|
|
17
23
|
}
|
|
18
24
|
if (opts.limit)
|
|
19
25
|
params.set('limit', String(opts.limit));
|
|
26
|
+
if (opts.cursor)
|
|
27
|
+
params.set('cursor', opts.cursor);
|
|
20
28
|
const url = `${opts.baseUrl.replace(/\/+$/, '')}/api/v1/registry/agents?${params}`;
|
|
21
29
|
const headers = {
|
|
22
30
|
[PROTOCOL_VERSION_HEADER]: CURRENT_PROTOCOL_VERSION,
|
|
@@ -30,3 +38,42 @@ export async function listAgentsAuthenticated(opts) {
|
|
|
30
38
|
}
|
|
31
39
|
return (await response.json());
|
|
32
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Fetch every agent by following the `next` cursor until it's null/absent.
|
|
43
|
+
*
|
|
44
|
+
* Uses the backend's maximum page size (100) to minimise round-trips. The
|
|
45
|
+
* optional `maxAgents` cap bounds the total number of agents collected and
|
|
46
|
+
* shrinks the final page request so we never over-fetch. The latest
|
|
47
|
+
* `totalCount`/`totalOnlineCount` seen are carried forward to the result.
|
|
48
|
+
*/
|
|
49
|
+
export async function listAllAgentsAuthenticated(opts) {
|
|
50
|
+
const agents = [];
|
|
51
|
+
let cursor = opts.cursor;
|
|
52
|
+
let totalCount;
|
|
53
|
+
let totalOnlineCount;
|
|
54
|
+
const cap = opts.maxAgents;
|
|
55
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
56
|
+
const remaining = cap !== undefined ? cap - agents.length : undefined;
|
|
57
|
+
if (remaining !== undefined && remaining <= 0)
|
|
58
|
+
break;
|
|
59
|
+
const pageSize = remaining !== undefined
|
|
60
|
+
? Math.min(REGISTRY_PAGE_SIZE, remaining)
|
|
61
|
+
: REGISTRY_PAGE_SIZE;
|
|
62
|
+
const result = await listAgentsAuthenticated({
|
|
63
|
+
...opts,
|
|
64
|
+
limit: pageSize,
|
|
65
|
+
cursor,
|
|
66
|
+
});
|
|
67
|
+
agents.push(...result.agents);
|
|
68
|
+
if (result.totalCount !== undefined)
|
|
69
|
+
totalCount = result.totalCount;
|
|
70
|
+
if (result.totalOnlineCount !== undefined) {
|
|
71
|
+
totalOnlineCount = result.totalOnlineCount;
|
|
72
|
+
}
|
|
73
|
+
cursor = result.next ?? undefined;
|
|
74
|
+
if (!cursor)
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
const trimmed = cap !== undefined ? agents.slice(0, cap) : agents;
|
|
78
|
+
return { agents: trimmed, totalCount, totalOnlineCount, next: null };
|
|
79
|
+
}
|
package/dist/tools.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { textPart, filePartFromPath, type TaskInfo, type SendMessageRequestPart } from '@blocks-network/sdk';
|
|
9
9
|
import type { ListAgentsResult } from './registry-list.js';
|
|
10
|
-
import type
|
|
10
|
+
import { type AgentStatusResponse } from './agent-status.js';
|
|
11
11
|
import type { ConsumerBalance, TopUpSession } from './billing.js';
|
|
12
12
|
export interface ArtifactRef {
|
|
13
13
|
fileName?: string;
|
|
@@ -105,9 +105,12 @@ export interface ToolDeps {
|
|
|
105
105
|
listAgents(options: {
|
|
106
106
|
baseUrl: string;
|
|
107
107
|
apiKey?: string;
|
|
108
|
+
/** Free-text search query (`q`); matches agent name, description, tags, etc. */
|
|
109
|
+
q?: string;
|
|
108
110
|
tag?: string;
|
|
109
111
|
listing?: 'public' | 'private';
|
|
110
|
-
|
|
112
|
+
/** Optional cap on the total number of agents to fetch across all pages. */
|
|
113
|
+
maxAgents?: number;
|
|
111
114
|
}): Promise<ListAgentsResult>;
|
|
112
115
|
validateFilePath(filePath: string): string;
|
|
113
116
|
resolveSavePath(filePath: string): string;
|
|
@@ -158,6 +161,25 @@ export interface ListAgentsParams {
|
|
|
158
161
|
tag?: string;
|
|
159
162
|
listing?: 'public' | 'private';
|
|
160
163
|
limit?: number;
|
|
164
|
+
/**
|
|
165
|
+
* Include agents that have no online instances. Defaults to false: the
|
|
166
|
+
* registry returns every registered agent regardless of whether any
|
|
167
|
+
* instance is currently running, so by default we filter to agents with
|
|
168
|
+
* at least one online instance to avoid surfacing unreachable agents.
|
|
169
|
+
*/
|
|
170
|
+
includeOffline?: boolean;
|
|
171
|
+
}
|
|
172
|
+
export interface SearchAgentsParams {
|
|
173
|
+
/** Free-text search query; matches agent name, description, tags, etc. */
|
|
174
|
+
query: string;
|
|
175
|
+
tag?: string;
|
|
176
|
+
listing?: 'public' | 'private';
|
|
177
|
+
limit?: number;
|
|
178
|
+
/**
|
|
179
|
+
* Include agents that have no online instances. Defaults to false so we
|
|
180
|
+
* only surface agents that can actually take a task. See ListAgentsParams.
|
|
181
|
+
*/
|
|
182
|
+
includeOffline?: boolean;
|
|
161
183
|
}
|
|
162
184
|
export interface GetAgentCardParams {
|
|
163
185
|
agentName: string;
|
|
@@ -185,6 +207,7 @@ export declare function getTask(params: GetTaskParams, deps: ToolDeps): Promise<
|
|
|
185
207
|
export declare function listTasks(params: ListTasksParams, deps: ToolDeps): Promise<ToolResult>;
|
|
186
208
|
export declare function cancelTask(params: CancelTaskParams, deps: ToolDeps): Promise<ToolResult>;
|
|
187
209
|
export declare function listAgents(params: ListAgentsParams, deps: ToolDeps): Promise<ToolResult>;
|
|
210
|
+
export declare function searchAgents(params: SearchAgentsParams, deps: ToolDeps): Promise<ToolResult>;
|
|
188
211
|
export declare function getAgentCard(params: GetAgentCardParams, deps: ToolDeps): Promise<ToolResult>;
|
|
189
212
|
export declare function connectTask(params: ConnectTaskParams, deps: ToolDeps): Promise<ToolResult>;
|
|
190
213
|
export declare function downloadArtifact(params: DownloadArtifactParams, deps: ToolDeps): Promise<ToolResult>;
|
package/dist/tools.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* touching the network or the filesystem.
|
|
7
7
|
*/
|
|
8
8
|
import { statSync } from 'node:fs';
|
|
9
|
+
import { MAX_AGENT_NAMES, AGENT_NAME_PATTERN, } from './agent-status.js';
|
|
9
10
|
// ============================================================================
|
|
10
11
|
// Helpers
|
|
11
12
|
// ============================================================================
|
|
@@ -163,15 +164,87 @@ export async function listAgents(params, deps) {
|
|
|
163
164
|
apiKey,
|
|
164
165
|
tag: params.tag,
|
|
165
166
|
listing: params.listing,
|
|
166
|
-
|
|
167
|
+
maxAgents: params.limit,
|
|
167
168
|
});
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
169
|
+
const total = result.totalCount ?? result.agents.length;
|
|
170
|
+
// The registry returns every registered agent regardless of whether any
|
|
171
|
+
// instance is currently running. Default to online-only so we never
|
|
172
|
+
// surface agents that can't actually take a task; `includeOffline` opts
|
|
173
|
+
// back into the full registry view.
|
|
174
|
+
if (params.includeOffline) {
|
|
175
|
+
const lines = result.agents.map(formatAgentRow);
|
|
176
|
+
const header = `Agents (${result.agents.length} of ${total} total):`;
|
|
177
|
+
return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
|
|
178
|
+
}
|
|
179
|
+
const online = await filterOnlineAgents(result.agents, { baseUrl, apiKey }, deps);
|
|
180
|
+
const lines = online.map(formatAgentRow);
|
|
181
|
+
const header = `Agents (${online.length} online of ${total} total):`;
|
|
182
|
+
return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
|
|
183
|
+
}
|
|
184
|
+
// ============================================================================
|
|
185
|
+
// search_agent
|
|
186
|
+
// ============================================================================
|
|
187
|
+
export async function searchAgents(params, deps) {
|
|
188
|
+
const query = params.query.trim();
|
|
189
|
+
if (!query) {
|
|
190
|
+
return {
|
|
191
|
+
content: [{ type: 'text', text: 'Search query must not be empty.' }],
|
|
192
|
+
isError: true,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const baseUrl = await deps.getBaseUrl();
|
|
196
|
+
const apiKey = deps.getApiKey();
|
|
197
|
+
const result = await deps.listAgents({
|
|
198
|
+
baseUrl,
|
|
199
|
+
apiKey,
|
|
200
|
+
q: query,
|
|
201
|
+
tag: params.tag,
|
|
202
|
+
listing: params.listing,
|
|
203
|
+
maxAgents: params.limit,
|
|
171
204
|
});
|
|
172
|
-
const
|
|
205
|
+
const total = result.totalCount ?? result.agents.length;
|
|
206
|
+
// Mirror list_agents: default to online-only so we never surface agents
|
|
207
|
+
// that can't actually take a task; `includeOffline` opts back into the
|
|
208
|
+
// full set of matches.
|
|
209
|
+
if (params.includeOffline) {
|
|
210
|
+
const lines = result.agents.map(formatAgentRow);
|
|
211
|
+
const header = `Agents matching "${query}" (${result.agents.length} of ${total} total):`;
|
|
212
|
+
return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
|
|
213
|
+
}
|
|
214
|
+
const online = await filterOnlineAgents(result.agents, { baseUrl, apiKey }, deps);
|
|
215
|
+
const lines = online.map(formatAgentRow);
|
|
216
|
+
const header = `Agents matching "${query}" (${online.length} online of ${total} total):`;
|
|
173
217
|
return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
|
|
174
218
|
}
|
|
219
|
+
function formatAgentRow(a) {
|
|
220
|
+
const tags = a.tags?.map((t) => t.name).join(', ') ?? '';
|
|
221
|
+
return `${a.agentName} | ${a.name ?? a.agentName} | ${a.listing ?? 'public'} | ${tags}`;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Keep only agents that have at least one online instance, as reported by
|
|
225
|
+
* the agent-status service. Agent names that the status endpoint can't
|
|
226
|
+
* accept (too many, or invalid characters) are queried in valid batches;
|
|
227
|
+
* any name that's unqueryable is treated as offline and dropped.
|
|
228
|
+
*/
|
|
229
|
+
async function filterOnlineAgents(agents, ctx, deps) {
|
|
230
|
+
const queryable = agents.filter((a) => AGENT_NAME_PATTERN.test(a.agentName));
|
|
231
|
+
if (queryable.length === 0)
|
|
232
|
+
return [];
|
|
233
|
+
const online = new Set();
|
|
234
|
+
for (let i = 0; i < queryable.length; i += MAX_AGENT_NAMES) {
|
|
235
|
+
const batch = queryable.slice(i, i + MAX_AGENT_NAMES);
|
|
236
|
+
const status = await deps.fetchAgentStatus({
|
|
237
|
+
baseUrl: ctx.baseUrl,
|
|
238
|
+
apiKey: ctx.apiKey,
|
|
239
|
+
agentNames: batch.map((a) => a.agentName),
|
|
240
|
+
});
|
|
241
|
+
for (const [name, info] of Object.entries(status.agents)) {
|
|
242
|
+
if ((info.onlineCount ?? 0) > 0)
|
|
243
|
+
online.add(name);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return agents.filter((a) => online.has(a.agentName));
|
|
247
|
+
}
|
|
175
248
|
// ============================================================================
|
|
176
249
|
// get_agent_card
|
|
177
250
|
// ============================================================================
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
BLOCKS_MAX_UPLOAD_BYTES,
|
|
15
15
|
} from '@blocks-network/sdk';
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import { listAllAgentsAuthenticated } from './registry-list.js';
|
|
18
18
|
import { fetchAgentStatus, MAX_AGENT_NAMES } from './agent-status.js';
|
|
19
19
|
import { getConsumerBalance, createConsumerTopUp } from './billing.js';
|
|
20
20
|
import {
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
resumeTask,
|
|
27
27
|
retryTask,
|
|
28
28
|
listAgents,
|
|
29
|
+
searchAgents,
|
|
29
30
|
getAgentCard,
|
|
30
31
|
getAgentStatus,
|
|
31
32
|
connectTask,
|
|
@@ -106,7 +107,7 @@ const deps: ToolDeps = {
|
|
|
106
107
|
getTaskClient,
|
|
107
108
|
getAgentByName: (agentName, options) =>
|
|
108
109
|
getAgent(agentName, options) as Promise<AgentEntryLike | null>,
|
|
109
|
-
listAgents:
|
|
110
|
+
listAgents: listAllAgentsAuthenticated,
|
|
110
111
|
fetchAgentStatus,
|
|
111
112
|
getConsumerBalance,
|
|
112
113
|
createConsumerTopUp,
|
|
@@ -193,18 +194,47 @@ server.tool(
|
|
|
193
194
|
|
|
194
195
|
server.tool(
|
|
195
196
|
'list_agents',
|
|
196
|
-
'List available agents in the Blocks Network registry. Use listing="private" with an API key to discover your private agents.',
|
|
197
|
+
'List available agents in the Blocks Network registry. By default only agents with at least one online instance are returned; set includeOffline=true to also list registered agents that are currently offline. Use listing="private" with an API key to discover your private agents.',
|
|
197
198
|
{
|
|
198
199
|
tag: z.string().optional().describe('Filter by tag slug'),
|
|
199
200
|
listing: z
|
|
200
201
|
.enum(['public', 'private'])
|
|
201
202
|
.optional()
|
|
202
203
|
.describe('Filter by listing visibility (default: public)'),
|
|
203
|
-
limit: z
|
|
204
|
+
limit: z
|
|
205
|
+
.number()
|
|
206
|
+
.optional()
|
|
207
|
+
.describe('Max agents to fetch across all pages; omit to fetch all'),
|
|
208
|
+
includeOffline: z
|
|
209
|
+
.boolean()
|
|
210
|
+
.optional()
|
|
211
|
+
.describe('Include agents with no online instances (default: false)'),
|
|
204
212
|
},
|
|
205
213
|
(params) => listAgents(params, deps),
|
|
206
214
|
);
|
|
207
215
|
|
|
216
|
+
server.tool(
|
|
217
|
+
'search_agent',
|
|
218
|
+
'Search the Blocks Network registry for agents matching a free-text query. The query matches against agent name, display name, description, tags, provider, and category, and supports field qualifiers (e.g. "agentname:translate", tag:"data"), quoted phrases, and negation ("-deprecated"). By default only agents with at least one online instance are returned; set includeOffline=true to also include matching agents that are currently offline. Use listing="private" with an API key to search your private agents.',
|
|
219
|
+
{
|
|
220
|
+
query: z.string().describe('Free-text search query'),
|
|
221
|
+
tag: z.string().optional().describe('Additionally filter by tag slug'),
|
|
222
|
+
listing: z
|
|
223
|
+
.enum(['public', 'private'])
|
|
224
|
+
.optional()
|
|
225
|
+
.describe('Filter by listing visibility (default: public)'),
|
|
226
|
+
limit: z
|
|
227
|
+
.number()
|
|
228
|
+
.optional()
|
|
229
|
+
.describe('Max matching agents to fetch across all pages; omit to fetch all'),
|
|
230
|
+
includeOffline: z
|
|
231
|
+
.boolean()
|
|
232
|
+
.optional()
|
|
233
|
+
.describe('Include agents with no online instances (default: false)'),
|
|
234
|
+
},
|
|
235
|
+
(params) => searchAgents(params, deps),
|
|
236
|
+
);
|
|
237
|
+
|
|
208
238
|
server.tool(
|
|
209
239
|
'get_agent_card',
|
|
210
240
|
'Get the full agent card for a specific agent',
|
package/src/registry-list.ts
CHANGED
|
@@ -23,27 +23,44 @@ export interface AgentListEntry {
|
|
|
23
23
|
export interface ListAgentsOptions {
|
|
24
24
|
baseUrl: string;
|
|
25
25
|
apiKey?: string;
|
|
26
|
+
/** Free-text search query (`q`); matches agent name, description, tags, etc. */
|
|
27
|
+
q?: string;
|
|
26
28
|
tag?: string;
|
|
27
29
|
listing?: 'public' | 'private';
|
|
30
|
+
/** Page size for a single request. The backend caps this at 100. */
|
|
28
31
|
limit?: number;
|
|
32
|
+
/** Opaque pagination cursor returned as `next` by a previous request. */
|
|
33
|
+
cursor?: string;
|
|
29
34
|
fetchImpl?: typeof fetch;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
export interface ListAgentsResult {
|
|
33
38
|
agents: AgentListEntry[];
|
|
34
39
|
totalCount?: number;
|
|
40
|
+
/** Opaque cursor for the next page, or null/absent when there are no more. */
|
|
41
|
+
next?: string | null;
|
|
42
|
+
/** Total agents with at least one online instance (ignores cursor). */
|
|
43
|
+
totalOnlineCount?: number;
|
|
35
44
|
}
|
|
36
45
|
|
|
46
|
+
/** Backend maximum page size for GET /api/v1/registry/agents. */
|
|
47
|
+
export const REGISTRY_PAGE_SIZE = 100;
|
|
48
|
+
|
|
49
|
+
/** Safety backstop so a misbehaving (never-null) cursor can't loop forever. */
|
|
50
|
+
const MAX_PAGES = 1000;
|
|
51
|
+
|
|
37
52
|
export async function listAgentsAuthenticated(
|
|
38
53
|
opts: ListAgentsOptions,
|
|
39
54
|
): Promise<ListAgentsResult> {
|
|
40
55
|
const params = new URLSearchParams({ include: 'full' });
|
|
56
|
+
if (opts.q) params.set('q', opts.q);
|
|
41
57
|
if (opts.tag) params.set('tag', opts.tag);
|
|
42
58
|
if (opts.listing) {
|
|
43
59
|
params.set('listing', opts.listing);
|
|
44
60
|
if (opts.listing === 'private') params.set('scope', 'owned');
|
|
45
61
|
}
|
|
46
62
|
if (opts.limit) params.set('limit', String(opts.limit));
|
|
63
|
+
if (opts.cursor) params.set('cursor', opts.cursor);
|
|
47
64
|
|
|
48
65
|
const url = `${opts.baseUrl.replace(/\/+$/, '')}/api/v1/registry/agents?${params}`;
|
|
49
66
|
const headers: Record<string, string> = {
|
|
@@ -58,3 +75,47 @@ export async function listAgentsAuthenticated(
|
|
|
58
75
|
}
|
|
59
76
|
return (await response.json()) as ListAgentsResult;
|
|
60
77
|
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Fetch every agent by following the `next` cursor until it's null/absent.
|
|
81
|
+
*
|
|
82
|
+
* Uses the backend's maximum page size (100) to minimise round-trips. The
|
|
83
|
+
* optional `maxAgents` cap bounds the total number of agents collected and
|
|
84
|
+
* shrinks the final page request so we never over-fetch. The latest
|
|
85
|
+
* `totalCount`/`totalOnlineCount` seen are carried forward to the result.
|
|
86
|
+
*/
|
|
87
|
+
export async function listAllAgentsAuthenticated(
|
|
88
|
+
opts: ListAgentsOptions & { maxAgents?: number },
|
|
89
|
+
): Promise<ListAgentsResult> {
|
|
90
|
+
const agents: AgentListEntry[] = [];
|
|
91
|
+
let cursor = opts.cursor;
|
|
92
|
+
let totalCount: number | undefined;
|
|
93
|
+
let totalOnlineCount: number | undefined;
|
|
94
|
+
const cap = opts.maxAgents;
|
|
95
|
+
|
|
96
|
+
for (let page = 0; page < MAX_PAGES; page++) {
|
|
97
|
+
const remaining = cap !== undefined ? cap - agents.length : undefined;
|
|
98
|
+
if (remaining !== undefined && remaining <= 0) break;
|
|
99
|
+
const pageSize =
|
|
100
|
+
remaining !== undefined
|
|
101
|
+
? Math.min(REGISTRY_PAGE_SIZE, remaining)
|
|
102
|
+
: REGISTRY_PAGE_SIZE;
|
|
103
|
+
|
|
104
|
+
const result = await listAgentsAuthenticated({
|
|
105
|
+
...opts,
|
|
106
|
+
limit: pageSize,
|
|
107
|
+
cursor,
|
|
108
|
+
});
|
|
109
|
+
agents.push(...result.agents);
|
|
110
|
+
if (result.totalCount !== undefined) totalCount = result.totalCount;
|
|
111
|
+
if (result.totalOnlineCount !== undefined) {
|
|
112
|
+
totalOnlineCount = result.totalOnlineCount;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
cursor = result.next ?? undefined;
|
|
116
|
+
if (!cursor) break;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const trimmed = cap !== undefined ? agents.slice(0, cap) : agents;
|
|
120
|
+
return { agents: trimmed, totalCount, totalOnlineCount, next: null };
|
|
121
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -15,7 +15,11 @@ import {
|
|
|
15
15
|
} from '@blocks-network/sdk';
|
|
16
16
|
|
|
17
17
|
import type { ListAgentsResult } from './registry-list.js';
|
|
18
|
-
import
|
|
18
|
+
import {
|
|
19
|
+
MAX_AGENT_NAMES,
|
|
20
|
+
AGENT_NAME_PATTERN,
|
|
21
|
+
type AgentStatusResponse,
|
|
22
|
+
} from './agent-status.js';
|
|
19
23
|
import type { ConsumerBalance, TopUpSession } from './billing.js';
|
|
20
24
|
|
|
21
25
|
// ============================================================================
|
|
@@ -120,9 +124,12 @@ export interface ToolDeps {
|
|
|
120
124
|
listAgents(options: {
|
|
121
125
|
baseUrl: string;
|
|
122
126
|
apiKey?: string;
|
|
127
|
+
/** Free-text search query (`q`); matches agent name, description, tags, etc. */
|
|
128
|
+
q?: string;
|
|
123
129
|
tag?: string;
|
|
124
130
|
listing?: 'public' | 'private';
|
|
125
|
-
|
|
131
|
+
/** Optional cap on the total number of agents to fetch across all pages. */
|
|
132
|
+
maxAgents?: number;
|
|
126
133
|
}): Promise<ListAgentsResult>;
|
|
127
134
|
validateFilePath(filePath: string): string;
|
|
128
135
|
resolveSavePath(filePath: string): string;
|
|
@@ -223,6 +230,26 @@ export interface ListAgentsParams {
|
|
|
223
230
|
tag?: string;
|
|
224
231
|
listing?: 'public' | 'private';
|
|
225
232
|
limit?: number;
|
|
233
|
+
/**
|
|
234
|
+
* Include agents that have no online instances. Defaults to false: the
|
|
235
|
+
* registry returns every registered agent regardless of whether any
|
|
236
|
+
* instance is currently running, so by default we filter to agents with
|
|
237
|
+
* at least one online instance to avoid surfacing unreachable agents.
|
|
238
|
+
*/
|
|
239
|
+
includeOffline?: boolean;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface SearchAgentsParams {
|
|
243
|
+
/** Free-text search query; matches agent name, description, tags, etc. */
|
|
244
|
+
query: string;
|
|
245
|
+
tag?: string;
|
|
246
|
+
listing?: 'public' | 'private';
|
|
247
|
+
limit?: number;
|
|
248
|
+
/**
|
|
249
|
+
* Include agents that have no online instances. Defaults to false so we
|
|
250
|
+
* only surface agents that can actually take a task. See ListAgentsParams.
|
|
251
|
+
*/
|
|
252
|
+
includeOffline?: boolean;
|
|
226
253
|
}
|
|
227
254
|
|
|
228
255
|
export interface GetAgentCardParams {
|
|
@@ -413,17 +440,106 @@ export async function listAgents(
|
|
|
413
440
|
apiKey,
|
|
414
441
|
tag: params.tag,
|
|
415
442
|
listing: params.listing,
|
|
416
|
-
|
|
443
|
+
maxAgents: params.limit,
|
|
417
444
|
});
|
|
418
445
|
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
446
|
+
const total = result.totalCount ?? result.agents.length;
|
|
447
|
+
|
|
448
|
+
// The registry returns every registered agent regardless of whether any
|
|
449
|
+
// instance is currently running. Default to online-only so we never
|
|
450
|
+
// surface agents that can't actually take a task; `includeOffline` opts
|
|
451
|
+
// back into the full registry view.
|
|
452
|
+
if (params.includeOffline) {
|
|
453
|
+
const lines = result.agents.map(formatAgentRow);
|
|
454
|
+
const header = `Agents (${result.agents.length} of ${total} total):`;
|
|
455
|
+
return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const online = await filterOnlineAgents(result.agents, { baseUrl, apiKey }, deps);
|
|
459
|
+
const lines = online.map(formatAgentRow);
|
|
460
|
+
const header = `Agents (${online.length} online of ${total} total):`;
|
|
461
|
+
return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ============================================================================
|
|
465
|
+
// search_agent
|
|
466
|
+
// ============================================================================
|
|
467
|
+
|
|
468
|
+
export async function searchAgents(
|
|
469
|
+
params: SearchAgentsParams,
|
|
470
|
+
deps: ToolDeps,
|
|
471
|
+
): Promise<ToolResult> {
|
|
472
|
+
const query = params.query.trim();
|
|
473
|
+
if (!query) {
|
|
474
|
+
return {
|
|
475
|
+
content: [{ type: 'text', text: 'Search query must not be empty.' }],
|
|
476
|
+
isError: true,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const baseUrl = await deps.getBaseUrl();
|
|
481
|
+
const apiKey = deps.getApiKey();
|
|
482
|
+
const result = await deps.listAgents({
|
|
483
|
+
baseUrl,
|
|
484
|
+
apiKey,
|
|
485
|
+
q: query,
|
|
486
|
+
tag: params.tag,
|
|
487
|
+
listing: params.listing,
|
|
488
|
+
maxAgents: params.limit,
|
|
422
489
|
});
|
|
423
|
-
|
|
490
|
+
|
|
491
|
+
const total = result.totalCount ?? result.agents.length;
|
|
492
|
+
|
|
493
|
+
// Mirror list_agents: default to online-only so we never surface agents
|
|
494
|
+
// that can't actually take a task; `includeOffline` opts back into the
|
|
495
|
+
// full set of matches.
|
|
496
|
+
if (params.includeOffline) {
|
|
497
|
+
const lines = result.agents.map(formatAgentRow);
|
|
498
|
+
const header = `Agents matching "${query}" (${result.agents.length} of ${total} total):`;
|
|
499
|
+
return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const online = await filterOnlineAgents(result.agents, { baseUrl, apiKey }, deps);
|
|
503
|
+
const lines = online.map(formatAgentRow);
|
|
504
|
+
const header = `Agents matching "${query}" (${online.length} online of ${total} total):`;
|
|
424
505
|
return { content: [{ type: 'text', text: [header, ...lines].join('\n') }] };
|
|
425
506
|
}
|
|
426
507
|
|
|
508
|
+
function formatAgentRow(a: ListAgentsResult['agents'][number]): string {
|
|
509
|
+
const tags = a.tags?.map((t) => t.name).join(', ') ?? '';
|
|
510
|
+
return `${a.agentName} | ${a.name ?? a.agentName} | ${a.listing ?? 'public'} | ${tags}`;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Keep only agents that have at least one online instance, as reported by
|
|
515
|
+
* the agent-status service. Agent names that the status endpoint can't
|
|
516
|
+
* accept (too many, or invalid characters) are queried in valid batches;
|
|
517
|
+
* any name that's unqueryable is treated as offline and dropped.
|
|
518
|
+
*/
|
|
519
|
+
async function filterOnlineAgents(
|
|
520
|
+
agents: ListAgentsResult['agents'],
|
|
521
|
+
ctx: { baseUrl: string; apiKey?: string },
|
|
522
|
+
deps: ToolDeps,
|
|
523
|
+
): Promise<ListAgentsResult['agents']> {
|
|
524
|
+
const queryable = agents.filter((a) => AGENT_NAME_PATTERN.test(a.agentName));
|
|
525
|
+
if (queryable.length === 0) return [];
|
|
526
|
+
|
|
527
|
+
const online = new Set<string>();
|
|
528
|
+
for (let i = 0; i < queryable.length; i += MAX_AGENT_NAMES) {
|
|
529
|
+
const batch = queryable.slice(i, i + MAX_AGENT_NAMES);
|
|
530
|
+
const status = await deps.fetchAgentStatus({
|
|
531
|
+
baseUrl: ctx.baseUrl,
|
|
532
|
+
apiKey: ctx.apiKey,
|
|
533
|
+
agentNames: batch.map((a) => a.agentName),
|
|
534
|
+
});
|
|
535
|
+
for (const [name, info] of Object.entries(status.agents)) {
|
|
536
|
+
if ((info.onlineCount ?? 0) > 0) online.add(name);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return agents.filter((a) => online.has(a.agentName));
|
|
541
|
+
}
|
|
542
|
+
|
|
427
543
|
// ============================================================================
|
|
428
544
|
// get_agent_card
|
|
429
545
|
// ============================================================================
|
|
@@ -2,6 +2,16 @@ import { describe, it, expect } from 'vitest';
|
|
|
2
2
|
import { listAgents } from '../src/tools.js';
|
|
3
3
|
import { makeFakeDeps } from './helpers.js';
|
|
4
4
|
|
|
5
|
+
// A status response where every named agent has one online instance, so the
|
|
6
|
+
// default online-only filter is a no-op for these agents.
|
|
7
|
+
function allOnline(...names: string[]) {
|
|
8
|
+
const agents: Record<string, unknown> = {};
|
|
9
|
+
for (const name of names) {
|
|
10
|
+
agents[name] = { agentName: name, instances: [], onlineCount: 1, taskCount: 0 };
|
|
11
|
+
}
|
|
12
|
+
return { agents };
|
|
13
|
+
}
|
|
14
|
+
|
|
5
15
|
describe('list_agents', () => {
|
|
6
16
|
it('renders one row per agent with tag names joined by commas', async () => {
|
|
7
17
|
const { deps } = makeFakeDeps({
|
|
@@ -20,18 +30,19 @@ describe('list_agents', () => {
|
|
|
20
30
|
],
|
|
21
31
|
totalCount: 2,
|
|
22
32
|
},
|
|
33
|
+
agentStatusResult: allOnline('alice', 'bob'),
|
|
23
34
|
});
|
|
24
35
|
|
|
25
36
|
const res = await listAgents({}, deps);
|
|
26
37
|
const lines = res.content[0].text.split('\n');
|
|
27
38
|
expect(lines).toEqual([
|
|
28
|
-
'Agents (2):',
|
|
39
|
+
'Agents (2 online of 2 total):',
|
|
29
40
|
'alice | Alice | public | Translate, Summarize',
|
|
30
41
|
'bob | bob | private | ',
|
|
31
42
|
]);
|
|
32
43
|
});
|
|
33
44
|
|
|
34
|
-
it('forwards baseUrl, apiKey, tag, listing, limit to the registry helper', async () => {
|
|
45
|
+
it('forwards baseUrl, apiKey, tag, listing, limit (as maxAgents) to the registry helper', async () => {
|
|
35
46
|
const { deps, mocks } = makeFakeDeps({
|
|
36
47
|
apiKey: 'sk-test',
|
|
37
48
|
baseUrl: 'http://api.test',
|
|
@@ -47,7 +58,7 @@ describe('list_agents', () => {
|
|
|
47
58
|
apiKey: 'sk-test',
|
|
48
59
|
tag: 'translate',
|
|
49
60
|
listing: 'private',
|
|
50
|
-
|
|
61
|
+
maxAgents: 25,
|
|
51
62
|
});
|
|
52
63
|
});
|
|
53
64
|
|
|
@@ -57,18 +68,104 @@ describe('list_agents', () => {
|
|
|
57
68
|
agents: [{ agentName: 'naked', tags: [] }],
|
|
58
69
|
totalCount: 1,
|
|
59
70
|
},
|
|
71
|
+
agentStatusResult: allOnline('naked'),
|
|
60
72
|
});
|
|
61
73
|
|
|
62
74
|
const res = await listAgents({}, deps);
|
|
63
75
|
expect(res.content[0].text).toContain('naked | naked | public |');
|
|
64
76
|
});
|
|
65
77
|
|
|
66
|
-
it('uses
|
|
78
|
+
it('uses the online agent count for the header, falling back to length for total', async () => {
|
|
67
79
|
const { deps } = makeFakeDeps({
|
|
68
80
|
listAgentsResult: { agents: [{ agentName: 'a' }, { agentName: 'b' }] },
|
|
81
|
+
agentStatusResult: allOnline('a', 'b'),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const res = await listAgents({}, deps);
|
|
85
|
+
expect(res.content[0].text.split('\n')[0]).toBe('Agents (2 online of 2 total):');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('reports the registry total separately from the online count', async () => {
|
|
89
|
+
const { deps } = makeFakeDeps({
|
|
90
|
+
listAgentsResult: {
|
|
91
|
+
agents: [{ agentName: 'online' }, { agentName: 'offline' }],
|
|
92
|
+
totalCount: 50,
|
|
93
|
+
},
|
|
94
|
+
agentStatusResult: {
|
|
95
|
+
agents: {
|
|
96
|
+
online: { agentName: 'online', instances: [], onlineCount: 1, taskCount: 0 },
|
|
97
|
+
offline: { agentName: 'offline', instances: [], onlineCount: 0, taskCount: 0 },
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const res = await listAgents({}, deps);
|
|
103
|
+
expect(res.content[0].text.split('\n')[0]).toBe('Agents (1 online of 50 total):');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('drops offline agents by default, keeping only those with online instances', async () => {
|
|
107
|
+
const { deps, mocks } = makeFakeDeps({
|
|
108
|
+
listAgentsResult: {
|
|
109
|
+
agents: [
|
|
110
|
+
{ agentName: 'online', name: 'Online' },
|
|
111
|
+
{ agentName: 'offline', name: 'Offline' },
|
|
112
|
+
],
|
|
113
|
+
totalCount: 2,
|
|
114
|
+
},
|
|
115
|
+
agentStatusResult: {
|
|
116
|
+
agents: {
|
|
117
|
+
online: { agentName: 'online', instances: [], onlineCount: 1, taskCount: 0 },
|
|
118
|
+
offline: { agentName: 'offline', instances: [], onlineCount: 0, taskCount: 0 },
|
|
119
|
+
},
|
|
120
|
+
},
|
|
69
121
|
});
|
|
70
122
|
|
|
71
123
|
const res = await listAgents({}, deps);
|
|
72
|
-
|
|
124
|
+
const lines = res.content[0].text.split('\n');
|
|
125
|
+
expect(lines).toEqual([
|
|
126
|
+
'Agents (1 online of 2 total):',
|
|
127
|
+
'online | Online | public | ',
|
|
128
|
+
]);
|
|
129
|
+
expect(mocks.fetchAgentStatus).toHaveBeenCalledWith({
|
|
130
|
+
baseUrl: 'http://api.test',
|
|
131
|
+
apiKey: undefined,
|
|
132
|
+
agentNames: ['online', 'offline'],
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('includes offline agents and skips the status check when includeOffline is true', async () => {
|
|
137
|
+
const { deps, mocks } = makeFakeDeps({
|
|
138
|
+
listAgentsResult: {
|
|
139
|
+
agents: [
|
|
140
|
+
{ agentName: 'online' },
|
|
141
|
+
{ agentName: 'offline' },
|
|
142
|
+
],
|
|
143
|
+
totalCount: 2,
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const res = await listAgents({ includeOffline: true }, deps);
|
|
148
|
+
expect(res.content[0].text.split('\n')[0]).toBe('Agents (2 of 2 total):');
|
|
149
|
+
expect(mocks.fetchAgentStatus).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('drops agents whose names the status endpoint cannot query', async () => {
|
|
153
|
+
const { deps } = makeFakeDeps({
|
|
154
|
+
listAgentsResult: {
|
|
155
|
+
agents: [
|
|
156
|
+
{ agentName: 'valid_name' },
|
|
157
|
+
{ agentName: 'has-dash' },
|
|
158
|
+
],
|
|
159
|
+
totalCount: 2,
|
|
160
|
+
},
|
|
161
|
+
agentStatusResult: allOnline('valid_name'),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const res = await listAgents({}, deps);
|
|
165
|
+
const lines = res.content[0].text.split('\n');
|
|
166
|
+
expect(lines).toEqual([
|
|
167
|
+
'Agents (1 online of 2 total):',
|
|
168
|
+
'valid_name | valid_name | public | ',
|
|
169
|
+
]);
|
|
73
170
|
});
|
|
74
171
|
});
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
listAgentsAuthenticated,
|
|
4
|
+
listAllAgentsAuthenticated,
|
|
5
|
+
} from '../src/registry-list.js';
|
|
3
6
|
import {
|
|
4
7
|
PROTOCOL_VERSION_HEADER,
|
|
5
8
|
CURRENT_PROTOCOL_VERSION,
|
|
@@ -117,4 +120,125 @@ describe('listAgentsAuthenticated', () => {
|
|
|
117
120
|
});
|
|
118
121
|
expect(result).toEqual(body);
|
|
119
122
|
});
|
|
123
|
+
|
|
124
|
+
it('forwards the cursor query param when provided', async () => {
|
|
125
|
+
const fetchImpl = vi.fn().mockResolvedValue(
|
|
126
|
+
mockResponse({ agents: [], totalCount: 0 }),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
await listAgentsAuthenticated({
|
|
130
|
+
baseUrl: 'http://api.test',
|
|
131
|
+
cursor: 'abc123',
|
|
132
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const [url] = fetchImpl.mock.calls[0];
|
|
136
|
+
expect(new URL(String(url)).searchParams.get('cursor')).toBe('abc123');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('listAllAgentsAuthenticated', () => {
|
|
141
|
+
function agent(name: string) {
|
|
142
|
+
return { agentName: name, tags: [] };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
it('follows the next cursor across pages and aggregates results', async () => {
|
|
146
|
+
const fetchImpl = vi
|
|
147
|
+
.fn()
|
|
148
|
+
.mockResolvedValueOnce(
|
|
149
|
+
mockResponse({
|
|
150
|
+
agents: [agent('a'), agent('b')],
|
|
151
|
+
next: 'c1',
|
|
152
|
+
totalCount: 5,
|
|
153
|
+
totalOnlineCount: 3,
|
|
154
|
+
}),
|
|
155
|
+
)
|
|
156
|
+
.mockResolvedValueOnce(
|
|
157
|
+
mockResponse({ agents: [agent('c'), agent('d')], next: 'c2' }),
|
|
158
|
+
)
|
|
159
|
+
.mockResolvedValueOnce(
|
|
160
|
+
mockResponse({ agents: [agent('e')], next: null }),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const result = await listAllAgentsAuthenticated({
|
|
164
|
+
baseUrl: 'http://api.test',
|
|
165
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(result.agents.map((a) => a.agentName)).toEqual(['a', 'b', 'c', 'd', 'e']);
|
|
169
|
+
expect(result.totalCount).toBe(5);
|
|
170
|
+
expect(result.totalOnlineCount).toBe(3);
|
|
171
|
+
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
|
172
|
+
|
|
173
|
+
// First request carries no cursor; follow-ups carry the prior page's next.
|
|
174
|
+
expect(new URL(String(fetchImpl.mock.calls[0][0])).searchParams.get('cursor')).toBeNull();
|
|
175
|
+
expect(new URL(String(fetchImpl.mock.calls[1][0])).searchParams.get('cursor')).toBe('c1');
|
|
176
|
+
expect(new URL(String(fetchImpl.mock.calls[2][0])).searchParams.get('cursor')).toBe('c2');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('stops after a single page when next is absent', async () => {
|
|
180
|
+
const fetchImpl = vi.fn().mockResolvedValue(
|
|
181
|
+
mockResponse({ agents: [agent('a')], totalCount: 1 }),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const result = await listAllAgentsAuthenticated({
|
|
185
|
+
baseUrl: 'http://api.test',
|
|
186
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(fetchImpl).toHaveBeenCalledOnce();
|
|
190
|
+
expect(result.agents.map((a) => a.agentName)).toEqual(['a']);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('requests the max page size (limit=100) by default', async () => {
|
|
194
|
+
const fetchImpl = vi.fn().mockResolvedValue(
|
|
195
|
+
mockResponse({ agents: [], next: null }),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
await listAllAgentsAuthenticated({
|
|
199
|
+
baseUrl: 'http://api.test',
|
|
200
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(new URL(String(fetchImpl.mock.calls[0][0])).searchParams.get('limit')).toBe('100');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('caps the total agents and shrinks the final page request', async () => {
|
|
207
|
+
const fetchImpl = vi
|
|
208
|
+
.fn()
|
|
209
|
+
.mockResolvedValueOnce(
|
|
210
|
+
mockResponse({ agents: [agent('a'), agent('b')], next: 'c1' }),
|
|
211
|
+
)
|
|
212
|
+
.mockResolvedValueOnce(
|
|
213
|
+
mockResponse({ agents: [agent('c'), agent('d')], next: 'c2' }),
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const result = await listAllAgentsAuthenticated({
|
|
217
|
+
baseUrl: 'http://api.test',
|
|
218
|
+
maxAgents: 3,
|
|
219
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(result.agents.map((a) => a.agentName)).toEqual(['a', 'b', 'c']);
|
|
223
|
+
// Two requests only. The cap bounds each page size: first page asks for
|
|
224
|
+
// min(100, 3)=3; after 2 returned, the second asks for the remaining 1.
|
|
225
|
+
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
226
|
+
expect(new URL(String(fetchImpl.mock.calls[0][0])).searchParams.get('limit')).toBe('3');
|
|
227
|
+
expect(new URL(String(fetchImpl.mock.calls[1][0])).searchParams.get('limit')).toBe('1');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('terminates when a backend returns a stuck non-null cursor', async () => {
|
|
231
|
+
const fetchImpl = vi.fn().mockResolvedValue(
|
|
232
|
+
mockResponse({ agents: [agent('a')], next: 'always' }),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const result = await listAllAgentsAuthenticated({
|
|
236
|
+
baseUrl: 'http://api.test',
|
|
237
|
+
fetchImpl: fetchImpl as unknown as typeof fetch,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Bounded by the internal page guard rather than looping forever.
|
|
241
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1000);
|
|
242
|
+
expect(result.agents).toHaveLength(1000);
|
|
243
|
+
});
|
|
120
244
|
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { searchAgents } from '../src/tools.js';
|
|
3
|
+
import { makeFakeDeps } from './helpers.js';
|
|
4
|
+
|
|
5
|
+
// A status response where every named agent has one online instance, so the
|
|
6
|
+
// default online-only filter is a no-op for these agents.
|
|
7
|
+
function allOnline(...names: string[]) {
|
|
8
|
+
const agents: Record<string, unknown> = {};
|
|
9
|
+
for (const name of names) {
|
|
10
|
+
agents[name] = { agentName: name, instances: [], onlineCount: 1, taskCount: 0 };
|
|
11
|
+
}
|
|
12
|
+
return { agents };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('search_agent', () => {
|
|
16
|
+
it('forwards the query to the registry helper as `q`', async () => {
|
|
17
|
+
const { deps, mocks } = makeFakeDeps({
|
|
18
|
+
apiKey: 'sk-test',
|
|
19
|
+
baseUrl: 'http://api.test',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
await searchAgents(
|
|
23
|
+
{ query: 'translate', tag: 'language', listing: 'private', limit: 25 },
|
|
24
|
+
deps,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
expect(mocks.listAgents).toHaveBeenCalledWith({
|
|
28
|
+
baseUrl: 'http://api.test',
|
|
29
|
+
apiKey: 'sk-test',
|
|
30
|
+
q: 'translate',
|
|
31
|
+
tag: 'language',
|
|
32
|
+
listing: 'private',
|
|
33
|
+
maxAgents: 25,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('renders matching agents with the query in the header', async () => {
|
|
38
|
+
const { deps } = makeFakeDeps({
|
|
39
|
+
listAgentsResult: {
|
|
40
|
+
agents: [
|
|
41
|
+
{
|
|
42
|
+
agentName: 'alice',
|
|
43
|
+
name: 'Alice',
|
|
44
|
+
listing: 'public',
|
|
45
|
+
tags: [{ id: 'translate', name: 'Translate' }],
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
totalCount: 1,
|
|
49
|
+
},
|
|
50
|
+
agentStatusResult: allOnline('alice'),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const res = await searchAgents({ query: 'trans' }, deps);
|
|
54
|
+
const lines = res.content[0].text.split('\n');
|
|
55
|
+
expect(lines).toEqual([
|
|
56
|
+
'Agents matching "trans" (1 online of 1 total):',
|
|
57
|
+
'alice | Alice | public | Translate',
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('trims whitespace from the query before sending it', async () => {
|
|
62
|
+
const { deps, mocks } = makeFakeDeps();
|
|
63
|
+
|
|
64
|
+
await searchAgents({ query: ' hello ' }, deps);
|
|
65
|
+
|
|
66
|
+
expect(mocks.listAgents).toHaveBeenCalledWith(
|
|
67
|
+
expect.objectContaining({ q: 'hello' }),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('rejects an empty (or whitespace-only) query without calling the registry', async () => {
|
|
72
|
+
const { deps, mocks } = makeFakeDeps();
|
|
73
|
+
|
|
74
|
+
const res = await searchAgents({ query: ' ' }, deps);
|
|
75
|
+
expect(res.isError).toBe(true);
|
|
76
|
+
expect(res.content[0].text).toContain('must not be empty');
|
|
77
|
+
expect(mocks.listAgents).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('drops offline matches by default, keeping only online ones', async () => {
|
|
81
|
+
const { deps } = makeFakeDeps({
|
|
82
|
+
listAgentsResult: {
|
|
83
|
+
agents: [
|
|
84
|
+
{ agentName: 'online', name: 'Online' },
|
|
85
|
+
{ agentName: 'offline', name: 'Offline' },
|
|
86
|
+
],
|
|
87
|
+
totalCount: 2,
|
|
88
|
+
},
|
|
89
|
+
agentStatusResult: {
|
|
90
|
+
agents: {
|
|
91
|
+
online: { agentName: 'online', instances: [], onlineCount: 1, taskCount: 0 },
|
|
92
|
+
offline: { agentName: 'offline', instances: [], onlineCount: 0, taskCount: 0 },
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const res = await searchAgents({ query: 'foo' }, deps);
|
|
98
|
+
const lines = res.content[0].text.split('\n');
|
|
99
|
+
expect(lines).toEqual([
|
|
100
|
+
'Agents matching "foo" (1 online of 2 total):',
|
|
101
|
+
'online | Online | public | ',
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('includes offline matches and skips the status check when includeOffline is true', async () => {
|
|
106
|
+
const { deps, mocks } = makeFakeDeps({
|
|
107
|
+
listAgentsResult: {
|
|
108
|
+
agents: [{ agentName: 'online' }, { agentName: 'offline' }],
|
|
109
|
+
totalCount: 2,
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const res = await searchAgents({ query: 'foo', includeOffline: true }, deps);
|
|
114
|
+
expect(res.content[0].text.split('\n')[0]).toBe(
|
|
115
|
+
'Agents matching "foo" (2 of 2 total):',
|
|
116
|
+
);
|
|
117
|
+
expect(mocks.fetchAgentStatus).not.toHaveBeenCalled();
|
|
118
|
+
});
|
|
119
|
+
});
|