@kadi.build/core 0.3.4 → 0.5.0
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/dist/client.d.ts +138 -11
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +319 -21
- package/dist/client.js.map +1 -1
- package/dist/crypto.d.ts +88 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +120 -0
- package/dist/crypto.js.map +1 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +30 -6
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -1
- package/src/client.ts +2380 -0
- package/src/crypto.ts +153 -0
- package/src/errors.ts +292 -0
- package/src/index.ts +114 -0
- package/src/lockfile.ts +493 -0
- package/src/transports/broker.ts +682 -0
- package/src/transports/native.ts +307 -0
- package/src/transports/stdio.ts +580 -0
- package/src/types.ts +1011 -0
- package/src/zod.ts +69 -0
- package/tsconfig.json +52 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Broker transport for kadi-core v0.1.0
|
|
3
|
+
*
|
|
4
|
+
* Loads abilities that run on REMOTE agents connected to a broker.
|
|
5
|
+
* Communication happens via WebSocket using JSON-RPC.
|
|
6
|
+
*
|
|
7
|
+
* Protocol flow for invoking a remote tool:
|
|
8
|
+
* 1. Client sends kadi.ability.request → broker returns { status: 'pending', requestId }
|
|
9
|
+
* 2. Broker forwards request to provider agent
|
|
10
|
+
* 3. Provider executes tool and sends result back to broker
|
|
11
|
+
* 4. Broker sends kadi.ability.response notification with the actual result
|
|
12
|
+
*
|
|
13
|
+
* This transport doesn't manage the broker connection itself.
|
|
14
|
+
* It receives a BrokerState from KadiClient and uses it for communication.
|
|
15
|
+
*
|
|
16
|
+
* ═══════════════════════════════════════════════════════════════════════════════
|
|
17
|
+
* WORKAROUND NOTE: Ability Discovery
|
|
18
|
+
* ═══════════════════════════════════════════════════════════════════════════════
|
|
19
|
+
*
|
|
20
|
+
* The current broker is TOOL-CENTRIC (tracks tools, not abilities). There's no
|
|
21
|
+
* direct API to ask "What tools does agent X provide?"
|
|
22
|
+
*
|
|
23
|
+
* CURRENT WORKAROUND:
|
|
24
|
+
* 1. Call kadi.ability.list with includeProviders: true (fetches ALL tools)
|
|
25
|
+
* 2. Filter tools where provider.displayName matches the ability name
|
|
26
|
+
* 3. Use the provider's agentId for routing via _kadi hints
|
|
27
|
+
*
|
|
28
|
+
* FUTURE: When the broker implements kadi.agent.discover (see design doc at
|
|
29
|
+
* kadi-broker/docs/design/ABILITY_CENTRIC_DISCOVERY.md), replace the
|
|
30
|
+
* discoverAbilityWorkaround() function with a direct API call.
|
|
31
|
+
* ═══════════════════════════════════════════════════════════════════════════════
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import crypto from 'node:crypto';
|
|
35
|
+
import type {
|
|
36
|
+
LoadedAbility,
|
|
37
|
+
InvokeOptions,
|
|
38
|
+
ToolDefinition,
|
|
39
|
+
BrokerState,
|
|
40
|
+
JsonRpcRequest,
|
|
41
|
+
EventHandler,
|
|
42
|
+
BrokerEventHandler,
|
|
43
|
+
BrokerEvent,
|
|
44
|
+
} from '../types.js';
|
|
45
|
+
import { KadiError } from '../errors.js';
|
|
46
|
+
|
|
47
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
48
|
+
// TYPES
|
|
49
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Provider information returned by kadi.ability.list with includeProviders: true.
|
|
53
|
+
* This matches the ProviderSummary type from the broker.
|
|
54
|
+
*/
|
|
55
|
+
interface ProviderInfo {
|
|
56
|
+
sessionId: string;
|
|
57
|
+
agentId?: string;
|
|
58
|
+
displayName?: string;
|
|
59
|
+
source: 'kadi-agent' | 'mcp-upstream';
|
|
60
|
+
networks: string[];
|
|
61
|
+
tags?: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Tool with provider information from kadi.ability.list.
|
|
66
|
+
*/
|
|
67
|
+
interface ToolWithProviders {
|
|
68
|
+
name: string;
|
|
69
|
+
description?: string;
|
|
70
|
+
inputSchema?: Record<string, unknown>;
|
|
71
|
+
tags?: string[];
|
|
72
|
+
providers?: ProviderInfo[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Response from kadi.ability.list.
|
|
77
|
+
*/
|
|
78
|
+
interface AbilityListResponse {
|
|
79
|
+
tools: ToolWithProviders[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Information about a discovered ability on the broker.
|
|
84
|
+
*/
|
|
85
|
+
interface DiscoveredAbility {
|
|
86
|
+
/** Agent ID for routing and event filtering (stable across reconnects) */
|
|
87
|
+
agentId: string;
|
|
88
|
+
|
|
89
|
+
/** Display name of the agent (what we searched for) */
|
|
90
|
+
name: string;
|
|
91
|
+
|
|
92
|
+
/** Tools available on this ability */
|
|
93
|
+
tools: ToolDefinition[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Options for creating a broker transport.
|
|
98
|
+
*/
|
|
99
|
+
export interface BrokerTransportOptions {
|
|
100
|
+
/** The broker connection to use */
|
|
101
|
+
broker: BrokerState;
|
|
102
|
+
|
|
103
|
+
/** Timeout for requests in milliseconds (default: 600000 = 10 minutes) */
|
|
104
|
+
requestTimeout?: number;
|
|
105
|
+
|
|
106
|
+
/** Networks to filter by when discovering (default: all) */
|
|
107
|
+
networks?: string[];
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Subscribe to broker events.
|
|
111
|
+
* Provided by KadiClient to enable ability.on() for broker transport.
|
|
112
|
+
*/
|
|
113
|
+
subscribe?: (pattern: string, handler: BrokerEventHandler) => Promise<void>;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Unsubscribe from broker events.
|
|
117
|
+
* Provided by KadiClient to enable ability.off() for broker transport.
|
|
118
|
+
*/
|
|
119
|
+
unsubscribe?: (pattern: string, handler: BrokerEventHandler) => Promise<void>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
123
|
+
// REQUEST HELPERS
|
|
124
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Send a JSON-RPC request over WebSocket and wait for response.
|
|
128
|
+
*
|
|
129
|
+
* This is a low-level helper used by both discovery and invoke.
|
|
130
|
+
* The broker connection's pendingRequests map is used to match responses.
|
|
131
|
+
*/
|
|
132
|
+
async function sendBrokerRequest(
|
|
133
|
+
broker: BrokerState,
|
|
134
|
+
method: string,
|
|
135
|
+
params: unknown,
|
|
136
|
+
timeoutMs: number
|
|
137
|
+
): Promise<unknown> {
|
|
138
|
+
// Verify connection is active
|
|
139
|
+
if (!broker.ws || broker.status !== 'connected') {
|
|
140
|
+
throw new KadiError(
|
|
141
|
+
`Broker "${broker.name}" is not connected`,
|
|
142
|
+
'BROKER_NOT_CONNECTED',
|
|
143
|
+
{
|
|
144
|
+
broker: broker.name,
|
|
145
|
+
status: broker.status,
|
|
146
|
+
hint: 'Call client.connect() first',
|
|
147
|
+
}
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Generate unique request ID
|
|
152
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
153
|
+
|
|
154
|
+
// Build JSON-RPC request
|
|
155
|
+
const request: JsonRpcRequest = {
|
|
156
|
+
jsonrpc: '2.0',
|
|
157
|
+
id,
|
|
158
|
+
method,
|
|
159
|
+
params,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// Create promise that will be resolved when response arrives
|
|
163
|
+
return new Promise((resolve, reject) => {
|
|
164
|
+
// Set up timeout
|
|
165
|
+
const timeout = setTimeout(() => {
|
|
166
|
+
broker.pendingRequests.delete(id);
|
|
167
|
+
reject(
|
|
168
|
+
new KadiError(`Request "${method}" timed out after ${timeoutMs}ms`, 'BROKER_TIMEOUT', {
|
|
169
|
+
broker: broker.name,
|
|
170
|
+
method,
|
|
171
|
+
timeout: timeoutMs,
|
|
172
|
+
})
|
|
173
|
+
);
|
|
174
|
+
}, timeoutMs);
|
|
175
|
+
|
|
176
|
+
// Store in pending requests map
|
|
177
|
+
// The message handler in client.ts will resolve this when response arrives
|
|
178
|
+
broker.pendingRequests.set(id, {
|
|
179
|
+
resolve: (result: unknown) => {
|
|
180
|
+
clearTimeout(timeout);
|
|
181
|
+
resolve(result);
|
|
182
|
+
},
|
|
183
|
+
reject: (error: Error) => {
|
|
184
|
+
clearTimeout(timeout);
|
|
185
|
+
reject(error);
|
|
186
|
+
},
|
|
187
|
+
timeout,
|
|
188
|
+
method,
|
|
189
|
+
sentAt: new Date(),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Re-check connection before sending (could have disconnected during setup)
|
|
193
|
+
const ws = broker.ws;
|
|
194
|
+
if (!ws || broker.status !== 'connected') {
|
|
195
|
+
broker.pendingRequests.delete(id);
|
|
196
|
+
clearTimeout(timeout);
|
|
197
|
+
reject(
|
|
198
|
+
new KadiError(`Broker "${broker.name}" disconnected`, 'BROKER_NOT_CONNECTED', {
|
|
199
|
+
broker: broker.name,
|
|
200
|
+
})
|
|
201
|
+
);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Send the request
|
|
206
|
+
try {
|
|
207
|
+
ws.send(JSON.stringify(request));
|
|
208
|
+
} catch (error) {
|
|
209
|
+
broker.pendingRequests.delete(id);
|
|
210
|
+
clearTimeout(timeout);
|
|
211
|
+
reject(
|
|
212
|
+
new KadiError(`Failed to send request to broker "${broker.name}"`, 'BROKER_ERROR', {
|
|
213
|
+
broker: broker.name,
|
|
214
|
+
method,
|
|
215
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
216
|
+
})
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
223
|
+
// ABILITY DISCOVERY (WORKAROUND)
|
|
224
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Discover an ability on the broker by name.
|
|
228
|
+
*
|
|
229
|
+
* ┌─────────────────────────────────────────────────────────────────────────────┐
|
|
230
|
+
* │ WORKAROUND: This function uses kadi.ability.list + client-side filtering │
|
|
231
|
+
* │ │
|
|
232
|
+
* │ The broker is currently tool-centric and doesn't support querying by │
|
|
233
|
+
* │ agent/ability name directly. This workaround: │
|
|
234
|
+
* │ │
|
|
235
|
+
* │ 1. Fetches ALL tools with provider information │
|
|
236
|
+
* │ 2. Filters to find tools where provider.displayName matches abilityName │
|
|
237
|
+
* │ 3. Extracts the agentId for consistent routing │
|
|
238
|
+
* │ │
|
|
239
|
+
* │ TODO: Replace with kadi.agent.discover when broker supports it. │
|
|
240
|
+
* │ See: kadi-broker/docs/design/ABILITY_CENTRIC_DISCOVERY.md │
|
|
241
|
+
* └─────────────────────────────────────────────────────────────────────────────┘
|
|
242
|
+
*
|
|
243
|
+
* @param broker - The broker connection to query
|
|
244
|
+
* @param abilityName - Name of the ability/agent to find (matches displayName)
|
|
245
|
+
* @param options - Discovery options (timeout)
|
|
246
|
+
* @returns Information about the discovered ability
|
|
247
|
+
* @throws KadiError if ability not found
|
|
248
|
+
*/
|
|
249
|
+
async function discoverAbilityWorkaround(
|
|
250
|
+
broker: BrokerState,
|
|
251
|
+
abilityName: string,
|
|
252
|
+
options: { timeoutMs: number }
|
|
253
|
+
): Promise<DiscoveredAbility> {
|
|
254
|
+
// Step 1: Fetch all tools with provider information
|
|
255
|
+
// This is inefficient but necessary until the broker supports agent queries
|
|
256
|
+
const response = (await sendBrokerRequest(
|
|
257
|
+
broker,
|
|
258
|
+
'kadi.ability.list',
|
|
259
|
+
{ includeProviders: true },
|
|
260
|
+
options.timeoutMs
|
|
261
|
+
)) as AbilityListResponse;
|
|
262
|
+
|
|
263
|
+
if (!response?.tools) {
|
|
264
|
+
throw new KadiError(
|
|
265
|
+
`Failed to list tools from broker "${broker.name}"`,
|
|
266
|
+
'BROKER_ERROR',
|
|
267
|
+
{ broker: broker.name }
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Step 2: Filter tools to find those provided by the named agent
|
|
272
|
+
// We match on provider.displayName (the agent's name from registration)
|
|
273
|
+
const matchingTools: ToolDefinition[] = [];
|
|
274
|
+
let targetAgentId: string | undefined;
|
|
275
|
+
let targetDisplayName: string | undefined;
|
|
276
|
+
|
|
277
|
+
for (const tool of response.tools) {
|
|
278
|
+
if (!tool.providers) continue;
|
|
279
|
+
|
|
280
|
+
// Find a provider whose displayName matches our ability name
|
|
281
|
+
const matchingProvider = tool.providers.find(
|
|
282
|
+
(p) => p.displayName === abilityName
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
if (matchingProvider) {
|
|
286
|
+
// Extract the agentId (stable identifier for routing and event filtering)
|
|
287
|
+
// We use the first matching provider's agentId for all invocations
|
|
288
|
+
if (!targetAgentId && matchingProvider.agentId) {
|
|
289
|
+
targetAgentId = matchingProvider.agentId;
|
|
290
|
+
targetDisplayName = matchingProvider.displayName;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Add tool to our list (convert to ToolDefinition format)
|
|
294
|
+
matchingTools.push({
|
|
295
|
+
name: tool.name,
|
|
296
|
+
description: tool.description ?? '',
|
|
297
|
+
inputSchema: tool.inputSchema ?? { type: 'object' },
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Step 3: Validate we found the ability
|
|
303
|
+
if (!targetAgentId || matchingTools.length === 0) {
|
|
304
|
+
// Build list of available agents for helpful error message
|
|
305
|
+
const availableAgents = new Set<string>();
|
|
306
|
+
for (const tool of response.tools) {
|
|
307
|
+
for (const provider of tool.providers ?? []) {
|
|
308
|
+
if (provider.displayName) {
|
|
309
|
+
availableAgents.add(provider.displayName);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const agentList = [...availableAgents];
|
|
315
|
+
const hint = agentList.length === 0
|
|
316
|
+
? `No agents with tools found. Make sure the ability: (1) is connected via serve('broker'), (2) has at least one tool registered`
|
|
317
|
+
: `Available agents with tools: [${agentList.join(', ')}]. Is '${abilityName}' connected with tools?`;
|
|
318
|
+
|
|
319
|
+
throw new KadiError(
|
|
320
|
+
`Ability "${abilityName}" not found on broker "${broker.name}"`,
|
|
321
|
+
'ABILITY_NOT_FOUND',
|
|
322
|
+
{
|
|
323
|
+
abilityName,
|
|
324
|
+
broker: broker.name,
|
|
325
|
+
availableAgents: agentList,
|
|
326
|
+
hint,
|
|
327
|
+
}
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
agentId: targetAgentId,
|
|
333
|
+
name: targetDisplayName ?? abilityName,
|
|
334
|
+
tools: matchingTools,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
339
|
+
// REMOTE INVOCATION
|
|
340
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Invoke a tool on a remote agent via the broker.
|
|
344
|
+
*
|
|
345
|
+
* Uses the KADI async invocation pattern:
|
|
346
|
+
* 1. Generate requestId and set up response listener FIRST (avoids race condition)
|
|
347
|
+
* 2. Send kadi.ability.request with toolName, toolInput, and requestId
|
|
348
|
+
* 3. Broker returns { status: 'pending', requestId }
|
|
349
|
+
* 4. Wait for kadi.ability.response notification with the result
|
|
350
|
+
*
|
|
351
|
+
* For consistent routing to a specific agent, we inject _kadi routing hints
|
|
352
|
+
* into the toolInput. The broker strips these before forwarding to the provider.
|
|
353
|
+
*
|
|
354
|
+
* @param broker - The broker connection to use
|
|
355
|
+
* @param targetAgentId - Agent ID to route to (uses requireAgent hint)
|
|
356
|
+
* @param toolName - Name of the tool to invoke
|
|
357
|
+
* @param params - Parameters for the tool
|
|
358
|
+
* @param timeoutMs - Timeout in milliseconds
|
|
359
|
+
* @returns The tool's result
|
|
360
|
+
*/
|
|
361
|
+
async function invokeViaBroker<T>(
|
|
362
|
+
broker: BrokerState,
|
|
363
|
+
targetAgentId: string,
|
|
364
|
+
toolName: string,
|
|
365
|
+
params: unknown,
|
|
366
|
+
timeoutMs: number
|
|
367
|
+
): Promise<T> {
|
|
368
|
+
// Fail fast if broker isn't properly initialized
|
|
369
|
+
if (!broker.pendingInvocations) {
|
|
370
|
+
throw new KadiError(
|
|
371
|
+
`Broker "${broker.name}" is missing pendingInvocations map`,
|
|
372
|
+
'BROKER_ERROR',
|
|
373
|
+
{ broker: broker.name, hint: 'This is a bug in broker initialization' }
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Generate requestId FIRST, before any async operations.
|
|
378
|
+
// This allows us to set up the response listener before sending,
|
|
379
|
+
// eliminating the race condition where fast responses arrive
|
|
380
|
+
// before the listener exists.
|
|
381
|
+
const requestId = crypto.randomUUID();
|
|
382
|
+
|
|
383
|
+
// Set up result listener BEFORE sending the request.
|
|
384
|
+
// When the broker sends kadi.ability.response, the message handler
|
|
385
|
+
// will find this listener and resolve/reject the promise.
|
|
386
|
+
const resultPromise = new Promise<T>((resolve, reject) => {
|
|
387
|
+
const timeoutHandle = setTimeout(() => {
|
|
388
|
+
broker.pendingInvocations.delete(requestId);
|
|
389
|
+
reject(
|
|
390
|
+
new KadiError(`Tool "${toolName}" invocation timed out`, 'BROKER_TIMEOUT', {
|
|
391
|
+
broker: broker.name,
|
|
392
|
+
toolName,
|
|
393
|
+
requestId,
|
|
394
|
+
timeout: timeoutMs,
|
|
395
|
+
})
|
|
396
|
+
);
|
|
397
|
+
}, timeoutMs);
|
|
398
|
+
|
|
399
|
+
broker.pendingInvocations.set(requestId, {
|
|
400
|
+
resolve: (result: unknown) => {
|
|
401
|
+
clearTimeout(timeoutHandle);
|
|
402
|
+
resolve(result as T);
|
|
403
|
+
},
|
|
404
|
+
reject: (error: Error) => {
|
|
405
|
+
clearTimeout(timeoutHandle);
|
|
406
|
+
reject(error);
|
|
407
|
+
},
|
|
408
|
+
timeout: timeoutHandle,
|
|
409
|
+
toolName,
|
|
410
|
+
sentAt: new Date(),
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// Inject routing hint to ensure the broker routes to our target agent
|
|
415
|
+
// The _kadi property is stripped by the broker before forwarding to the provider
|
|
416
|
+
const paramsWithRouting = {
|
|
417
|
+
...(typeof params === 'object' && params !== null ? params : {}),
|
|
418
|
+
_kadi: { requireAgent: targetAgentId },
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
// Helper to clean up the pending invocation on failure
|
|
422
|
+
const cleanupPendingInvocation = () => {
|
|
423
|
+
const pending = broker.pendingInvocations.get(requestId);
|
|
424
|
+
if (pending) {
|
|
425
|
+
clearTimeout(pending.timeout);
|
|
426
|
+
broker.pendingInvocations.delete(requestId);
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
// NOW send the request (listener already exists, no race possible)
|
|
431
|
+
try {
|
|
432
|
+
const pendingResult = (await sendBrokerRequest(
|
|
433
|
+
broker,
|
|
434
|
+
'kadi.ability.request',
|
|
435
|
+
{
|
|
436
|
+
toolName,
|
|
437
|
+
toolInput: paramsWithRouting,
|
|
438
|
+
requestId,
|
|
439
|
+
},
|
|
440
|
+
timeoutMs
|
|
441
|
+
)) as { status: string; requestId?: string };
|
|
442
|
+
|
|
443
|
+
// Validate the broker accepted the request
|
|
444
|
+
if (pendingResult.status !== 'pending') {
|
|
445
|
+
cleanupPendingInvocation();
|
|
446
|
+
throw new KadiError(
|
|
447
|
+
'Unexpected response from broker: expected pending acknowledgment',
|
|
448
|
+
'BROKER_ERROR',
|
|
449
|
+
{
|
|
450
|
+
broker: broker.name,
|
|
451
|
+
toolName,
|
|
452
|
+
response: pendingResult,
|
|
453
|
+
}
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
} catch (error) {
|
|
457
|
+
// Clean up listener if request failed
|
|
458
|
+
cleanupPendingInvocation();
|
|
459
|
+
throw error;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return resultPromise;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
466
|
+
// BROKER TRANSPORT
|
|
467
|
+
// ═══════════════════════════════════════════════════════════════════════════════
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Load a remote ability via broker.
|
|
471
|
+
*
|
|
472
|
+
* This discovers an agent providing the ability and returns a LoadedAbility
|
|
473
|
+
* interface that invokes tools via the broker, consistently routing to that
|
|
474
|
+
* specific agent.
|
|
475
|
+
*
|
|
476
|
+
* @param abilityName - Name of the ability to load (matches agent's displayName)
|
|
477
|
+
* @param options - Broker connection and options
|
|
478
|
+
* @returns LoadedAbility that can invoke tools remotely
|
|
479
|
+
*
|
|
480
|
+
* @example
|
|
481
|
+
* ```typescript
|
|
482
|
+
* // The broker state comes from KadiClient
|
|
483
|
+
* const brokerState = client.getBrokerState('production');
|
|
484
|
+
*
|
|
485
|
+
* // Load a remote ability
|
|
486
|
+
* const analyzer = await loadBrokerTransport('text-analyzer', {
|
|
487
|
+
* broker: brokerState,
|
|
488
|
+
* requestTimeout: 60000,
|
|
489
|
+
* });
|
|
490
|
+
*
|
|
491
|
+
* // Invoke tools on the remote agent
|
|
492
|
+
* const result = await analyzer.invoke('analyze', { text: 'hello' });
|
|
493
|
+
*
|
|
494
|
+
* // All invocations go to the same agent (consistent routing)
|
|
495
|
+
* const result2 = await analyzer.invoke('summarize', { text: 'world' });
|
|
496
|
+
* ```
|
|
497
|
+
*/
|
|
498
|
+
export async function loadBrokerTransport(
|
|
499
|
+
abilityName: string,
|
|
500
|
+
options: BrokerTransportOptions
|
|
501
|
+
): Promise<LoadedAbility> {
|
|
502
|
+
const timeoutMs = options.requestTimeout ?? 600000; // 10 minutes default
|
|
503
|
+
|
|
504
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
505
|
+
// Step 1: Discover the ability on the broker
|
|
506
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
507
|
+
// TODO: Replace discoverAbilityWorkaround with kadi.agent.discover when
|
|
508
|
+
// the broker implements it. See: kadi-broker/docs/design/ABILITY_CENTRIC_DISCOVERY.md
|
|
509
|
+
const discovered = await discoverAbilityWorkaround(options.broker, abilityName, {
|
|
510
|
+
timeoutMs,
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
514
|
+
// Step 2: Set up event subscription tracking
|
|
515
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
516
|
+
// Maps user handlers to their wrapper handlers for cleanup in off()
|
|
517
|
+
// Structure: handler → (pattern → wrapper)
|
|
518
|
+
const handlerWrappers = new Map<EventHandler, Map<string, BrokerEventHandler>>();
|
|
519
|
+
|
|
520
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
521
|
+
// Step 3: Return LoadedAbility interface
|
|
522
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
523
|
+
// The agentId is used for routing hints, ensuring all invocations go to the
|
|
524
|
+
// same agent. This is stable across reconnects (unlike sessionId).
|
|
525
|
+
return {
|
|
526
|
+
name: discovered.name,
|
|
527
|
+
transport: 'broker',
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Invoke a tool on the remote agent.
|
|
531
|
+
*
|
|
532
|
+
* Routes through the broker to the target agent using _kadi.requireAgent
|
|
533
|
+
* routing hint. This ensures all calls go to the same agent that was
|
|
534
|
+
* discovered during loadBrokerTransport().
|
|
535
|
+
*
|
|
536
|
+
* @param toolName - Name of the tool to invoke
|
|
537
|
+
* @param params - Input parameters for the tool
|
|
538
|
+
* @param invokeOptions - Optional settings (timeout override)
|
|
539
|
+
*/
|
|
540
|
+
async invoke<T>(toolName: string, params: unknown, invokeOptions?: InvokeOptions): Promise<T> {
|
|
541
|
+
// Per-call timeout overrides the default timeout set when loading
|
|
542
|
+
const effectiveTimeout = invokeOptions?.timeout ?? timeoutMs;
|
|
543
|
+
|
|
544
|
+
try {
|
|
545
|
+
return await invokeViaBroker<T>(
|
|
546
|
+
options.broker,
|
|
547
|
+
discovered.agentId,
|
|
548
|
+
toolName,
|
|
549
|
+
params,
|
|
550
|
+
effectiveTimeout
|
|
551
|
+
);
|
|
552
|
+
} catch (error) {
|
|
553
|
+
// Re-throw KadiErrors as-is
|
|
554
|
+
if (error instanceof KadiError) {
|
|
555
|
+
throw error;
|
|
556
|
+
}
|
|
557
|
+
// Wrap other errors
|
|
558
|
+
throw new KadiError(`Remote tool "${toolName}" invocation failed`, 'TOOL_INVOCATION_FAILED', {
|
|
559
|
+
toolName,
|
|
560
|
+
ability: discovered.name,
|
|
561
|
+
targetAgent: discovered.agentId,
|
|
562
|
+
broker: options.broker.name,
|
|
563
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
},
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Get the list of tools this ability provides.
|
|
570
|
+
*
|
|
571
|
+
* Returns tools discovered from the agent during loadBrokerTransport().
|
|
572
|
+
* This is a snapshot from discovery time - if the agent registers new
|
|
573
|
+
* tools, you'd need to reload the ability to see them.
|
|
574
|
+
*/
|
|
575
|
+
getTools(): ToolDefinition[] {
|
|
576
|
+
return discovered.tools;
|
|
577
|
+
},
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Subscribe to events from this ability via the broker.
|
|
581
|
+
*
|
|
582
|
+
* Events are filtered to only include those from this specific ability
|
|
583
|
+
* (by matching the source agentId). The agentId is stable across reconnects.
|
|
584
|
+
*
|
|
585
|
+
* NOTE: Broker subscription is async but this method is sync for API consistency.
|
|
586
|
+
* If subscription fails, an error is logged but not thrown.
|
|
587
|
+
*
|
|
588
|
+
* @param pattern - Event pattern to subscribe to (e.g., 'order.*')
|
|
589
|
+
* @param handler - Handler function called with event data (not full BrokerEvent)
|
|
590
|
+
*/
|
|
591
|
+
on(pattern: string, handler: EventHandler): void {
|
|
592
|
+
if (!options.subscribe) {
|
|
593
|
+
throw new KadiError(
|
|
594
|
+
'Event subscriptions not available: subscribe callback not provided',
|
|
595
|
+
'NOT_IMPLEMENTED',
|
|
596
|
+
{
|
|
597
|
+
transport: 'broker',
|
|
598
|
+
hint: 'Ensure loadBroker() passes subscribe/unsubscribe callbacks',
|
|
599
|
+
}
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Create wrapper that filters by source (agentId) and extracts just the data
|
|
604
|
+
const wrapper: BrokerEventHandler = (event: BrokerEvent) => {
|
|
605
|
+
// Filter: only process events from this ability's agent
|
|
606
|
+
// source now contains agentId (stable across reconnects)
|
|
607
|
+
if (event.source === discovered.agentId) {
|
|
608
|
+
// Call user handler with just the data (matching stdio/native behavior)
|
|
609
|
+
handler(event.data);
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
// Track the wrapper for cleanup in off()
|
|
614
|
+
let patternMap = handlerWrappers.get(handler);
|
|
615
|
+
if (!patternMap) {
|
|
616
|
+
patternMap = new Map();
|
|
617
|
+
handlerWrappers.set(handler, patternMap);
|
|
618
|
+
}
|
|
619
|
+
patternMap.set(pattern, wrapper);
|
|
620
|
+
|
|
621
|
+
// Subscribe (fire-and-forget, errors logged by subscribe implementation)
|
|
622
|
+
options.subscribe(pattern, wrapper).catch((err) => {
|
|
623
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
624
|
+
console.error(`[KADI] ability.on() subscribe failed for "${pattern}": ${message}`);
|
|
625
|
+
});
|
|
626
|
+
},
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Unsubscribe from events.
|
|
630
|
+
*
|
|
631
|
+
* @param pattern - Event pattern to unsubscribe from
|
|
632
|
+
* @param handler - The same handler function passed to on()
|
|
633
|
+
*/
|
|
634
|
+
off(pattern: string, handler: EventHandler): void {
|
|
635
|
+
if (!options.unsubscribe) {
|
|
636
|
+
// Silently ignore - off() is cleanup, shouldn't throw during teardown
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Find the wrapper for this handler/pattern combo
|
|
641
|
+
const patternMap = handlerWrappers.get(handler);
|
|
642
|
+
if (!patternMap) return;
|
|
643
|
+
|
|
644
|
+
const wrapper = patternMap.get(pattern);
|
|
645
|
+
if (!wrapper) return;
|
|
646
|
+
|
|
647
|
+
// Clean up tracking
|
|
648
|
+
patternMap.delete(pattern);
|
|
649
|
+
if (patternMap.size === 0) {
|
|
650
|
+
handlerWrappers.delete(handler);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Unsubscribe (fire-and-forget)
|
|
654
|
+
options.unsubscribe(pattern, wrapper).catch((err) => {
|
|
655
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
656
|
+
console.error(`[KADI] ability.off() unsubscribe failed for "${pattern}": ${message}`);
|
|
657
|
+
});
|
|
658
|
+
},
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Disconnect and cleanup.
|
|
662
|
+
*
|
|
663
|
+
* For broker transport, cleans up event subscriptions. The broker connection
|
|
664
|
+
* itself is managed by KadiClient, not by individual abilities.
|
|
665
|
+
*/
|
|
666
|
+
async disconnect(): Promise<void> {
|
|
667
|
+
// Clean up all event subscriptions
|
|
668
|
+
if (options.unsubscribe) {
|
|
669
|
+
for (const [_handler, patternMap] of handlerWrappers) {
|
|
670
|
+
for (const [pattern, wrapper] of patternMap) {
|
|
671
|
+
try {
|
|
672
|
+
await options.unsubscribe(pattern, wrapper);
|
|
673
|
+
} catch {
|
|
674
|
+
// Ignore errors during cleanup
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
handlerWrappers.clear();
|
|
680
|
+
},
|
|
681
|
+
};
|
|
682
|
+
}
|