@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.
@@ -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
+ }