@kadi.build/core 0.0.1-alpha.4 → 0.0.1-alpha.6

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.
@@ -7,6 +7,7 @@ import { StdioTransport } from './transports/StdioTransport.js';
7
7
  import { createComponentLogger } from './utils/logger.js';
8
8
  import { IdFactory, KadiMessages } from './messages/BrokerMessages.js';
9
9
  import { loadAbility } from './loadAbility.js';
10
+ import { getAgentJSON } from './utils/agentUtils.js';
10
11
  // Internal event transport constants
11
12
  // These are NOT user events - they're internal mechanisms for transporting events between protocols
12
13
  /**
@@ -83,9 +84,10 @@ export class KadiClient extends EventEmitter {
83
84
  description;
84
85
  role;
85
86
  protocol;
86
- scope;
87
+ network;
87
88
  networks;
88
- brokerUrls;
89
+ brokers;
90
+ defaultBroker;
89
91
  abilityAgentJSON;
90
92
  logger;
91
93
  protocolHandler;
@@ -97,6 +99,24 @@ export class KadiClient extends EventEmitter {
97
99
  _idFactory;
98
100
  _pendingResponses = new Map();
99
101
  _pendingToolCalls = new Map();
102
+ _currentBroker; // The currently active broker name
103
+ /**
104
+ * Resolver function for the native protocol serve promise.
105
+ * When serve() is called with native protocol, it creates a promise to keep
106
+ * the process alive. This resolver allows us to resolve that promise during
107
+ * disconnect(), enabling clean shutdown of native abilities.
108
+ */
109
+ _nativeServePromiseResolve;
110
+ /**
111
+ * Stores all event subscriptions as a Map of pattern → array of callback functions
112
+ * Example structure:
113
+ * {
114
+ * 'user.login' => [handleLogin, logLoginEvent], // 2 functions listening to user.login
115
+ * 'payment.*' => [processPayment], // 1 function listening to all payment events
116
+ * 'system.shutdown' => [saveState, cleanupResources] // 2 functions for shutdown
117
+ * }
118
+ * When an event arrives, we check which patterns match and call all their callbacks
119
+ */
100
120
  _eventSubscriptions = new Map();
101
121
  constructor(options = {}) {
102
122
  super();
@@ -108,14 +128,17 @@ export class KadiClient extends EventEmitter {
108
128
  this.role = options.role || 'ability';
109
129
  this.protocol =
110
130
  options.protocol || process.env.KADI_PROTOCOL || 'native';
111
- this.scope = options.scope || 'global';
131
+ this.network = options.network || options.scope || 'global'; // Support legacy 'scope' parameter
112
132
  this.networks = options.networks || ['global'];
113
- this.brokerUrls =
114
- options.brokerUrls || (options.brokerUrl ? [options.brokerUrl] : []);
133
+ // Broker integration logic with agent.json fallback
134
+ this.brokers = this.resolveBrokerConfiguration(options);
135
+ this.defaultBroker = this.resolveDefaultBroker(options);
136
+ this._currentBroker = this.defaultBroker; // Initialize current broker to default
115
137
  this.abilityAgentJSON = options.abilityAgentJSON;
116
138
  // Initialize ID factory
117
139
  this._idFactory = new IdFactory();
118
140
  this.logger.lifecycle('constructor', `Creating client: ${this.name}@${this.version} (role: ${this.role}, protocol: ${this.protocol})`);
141
+ this.logger.debug('constructor', `Resolved ${Object.keys(this.brokers).length} brokers, default: ${this.defaultBroker}`);
119
142
  // Create protocol handler for stdio (broker is handled internally)
120
143
  if (this.protocol !== 'broker') {
121
144
  this.protocolHandler = ProtocolHandlerFactory.create(this.protocol, options);
@@ -129,6 +152,119 @@ export class KadiClient extends EventEmitter {
129
152
  }
130
153
  }
131
154
  }
155
+ /**
156
+ * Get the currently active broker name
157
+ */
158
+ get currentBroker() {
159
+ return this._currentBroker;
160
+ }
161
+ /**
162
+ * Set the currently active broker
163
+ * @param brokerName The name of the broker to use
164
+ * @throws Error if the broker name doesn't exist in configuration
165
+ */
166
+ setCurrentBroker(brokerName) {
167
+ if (!this.brokers[brokerName]) {
168
+ const availableBrokers = Object.keys(this.brokers);
169
+ throw new Error(`Broker '${brokerName}' not found in configuration.\n` +
170
+ `Available brokers: ${availableBrokers.join(', ') || 'none'}`);
171
+ }
172
+ this._currentBroker = brokerName;
173
+ this.logger.info('setCurrentBroker', `Switched current broker to: ${brokerName}`);
174
+ }
175
+ /**
176
+ * Get the current broker's connection (if connected)
177
+ */
178
+ getCurrentBrokerConnection() {
179
+ if (!this._currentBroker) {
180
+ throw new Error('No current broker set');
181
+ }
182
+ const brokerUrl = this.brokers[this._currentBroker];
183
+ const connection = this._brokerConnections.find((c) => c.url === brokerUrl);
184
+ if (!connection) {
185
+ throw new Error(`Not connected to broker '${this._currentBroker}' (${brokerUrl}). ` +
186
+ `Call connectToBrokers() first.`);
187
+ }
188
+ return connection;
189
+ }
190
+ /**
191
+ * Resolve broker configuration with agent.json integration
192
+ * Precedence: Code brokers > agent.json brokers > environment defaults
193
+ */
194
+ resolveBrokerConfiguration(options) {
195
+ this.logger.debug('resolveBrokerConfiguration', 'Entering broker resolution');
196
+ // If brokers specified in code, use them (override behavior)
197
+ if (options.brokers && Object.keys(options.brokers).length > 0) {
198
+ this.logger.info('resolveBrokerConfiguration', `Using ${Object.keys(options.brokers).length} brokers from code configuration`);
199
+ this.logger.debug('resolveBrokerConfiguration', `Code brokers: ${Object.keys(options.brokers).join(', ')}`);
200
+ return options.brokers;
201
+ }
202
+ // Try to load brokers from agent.json
203
+ try {
204
+ const agentJson = getAgentJSON();
205
+ if (agentJson?.brokers && Object.keys(agentJson.brokers).length > 0) {
206
+ this.logger.info('resolveBrokerConfiguration', `Using ${Object.keys(agentJson.brokers).length} brokers from agent.json`);
207
+ this.logger.debug('resolveBrokerConfiguration', `agent.json brokers: ${Object.keys(agentJson.brokers).join(', ')}`);
208
+ return agentJson.brokers;
209
+ }
210
+ else {
211
+ this.logger.debug('resolveBrokerConfiguration', 'No brokers found in agent.json');
212
+ }
213
+ }
214
+ catch (error) {
215
+ this.logger.warn('resolveBrokerConfiguration', `Failed to load agent.json: ${error instanceof Error ? error.message : error}`);
216
+ }
217
+ // Fallback to environment/default
218
+ const fallbackUrl = process.env.KADI_BROKER_URL || 'ws://localhost:8080';
219
+ const fallbackBrokers = { default: fallbackUrl };
220
+ this.logger.info('resolveBrokerConfiguration', `Using fallback broker configuration: default -> ${fallbackUrl}`);
221
+ this.logger.debug('resolveBrokerConfiguration', 'Exiting broker resolution with fallback');
222
+ return fallbackBrokers;
223
+ }
224
+ /**
225
+ * Resolve default broker with fallback logic
226
+ * Priority: Explicit defaultBroker > agent.json defaultBroker > 'prod' key > first broker key
227
+ */
228
+ resolveDefaultBroker(options) {
229
+ this.logger.debug('resolveDefaultBroker', 'Entering default broker resolution');
230
+ const brokerKeys = Object.keys(this.brokers);
231
+ // If no brokers available, no default possible
232
+ if (brokerKeys.length === 0) {
233
+ this.logger.warn('resolveDefaultBroker', 'No brokers available, cannot determine default');
234
+ return undefined;
235
+ }
236
+ // Check explicit defaultBroker from code
237
+ if (options.defaultBroker) {
238
+ if (this.brokers[options.defaultBroker]) {
239
+ this.logger.info('resolveDefaultBroker', `Using explicit default broker from code: ${options.defaultBroker}`);
240
+ return options.defaultBroker;
241
+ }
242
+ else {
243
+ this.logger.warn('resolveDefaultBroker', `Specified default broker '${options.defaultBroker}' not found in broker list`);
244
+ }
245
+ }
246
+ // Check defaultBroker from agent.json
247
+ try {
248
+ const agentJson = getAgentJSON();
249
+ if (agentJson?.defaultBroker && this.brokers[agentJson.defaultBroker]) {
250
+ this.logger.info('resolveDefaultBroker', `Using default broker from agent.json: ${agentJson.defaultBroker}`);
251
+ return agentJson.defaultBroker;
252
+ }
253
+ }
254
+ catch (error) {
255
+ this.logger.debug('resolveDefaultBroker', `Could not check agent.json for default broker: ${error instanceof Error ? error.message : error}`);
256
+ }
257
+ // Fallback to 'prod' if it exists
258
+ if (this.brokers['prod']) {
259
+ this.logger.info('resolveDefaultBroker', 'Using fallback default broker: prod');
260
+ return 'prod';
261
+ }
262
+ // Final fallback: first broker in the list
263
+ const firstBroker = brokerKeys[0];
264
+ this.logger.info('resolveDefaultBroker', `Using first available broker as default: ${firstBroker}`);
265
+ this.logger.debug('resolveDefaultBroker', 'Exiting default broker resolution');
266
+ return firstBroker;
267
+ }
132
268
  /**
133
269
  * Register a tool for this service
134
270
  *
@@ -213,15 +349,22 @@ export class KadiClient extends EventEmitter {
213
349
  return;
214
350
  }
215
351
  // For broker, send event message
352
+ // TODO: To make things consistent with the above, we can create a
353
+ // TODO: PublishEvent function inside BrokerTransport. Another reason
354
+ // TODO: why i think we should do this is because the BrokerTransport
355
+ // TODO: Implements the Transport interface which (optionally) has
356
+ // TODO: a publishEvent function
216
357
  if (this.protocol === 'broker' && this._brokerConnections.length > 0) {
217
358
  this.logger.debug('publishEvent', `Publishing broker event: ${eventName}`);
359
+ // TODO: Instead of picking the first broker, we should pick the
360
+ // TODO: the currently active broker name. See: "_currentBroker?: string;"
218
361
  const broker = this._brokerConnections[0];
219
362
  if (broker.ws && broker.ws.readyState === WebSocket.OPEN) {
220
363
  const eventMessage = {
221
364
  jsonrpc: '2.0',
222
365
  method: KadiMessages.EVENT_PUBLISH,
223
366
  params: {
224
- channel: eventName, // RabbitMQ calls this "routing key"
367
+ channel: eventName, // RabbitMQ calls this a "routing key"
225
368
  data
226
369
  }
227
370
  };
@@ -237,29 +380,77 @@ export class KadiClient extends EventEmitter {
237
380
  }
238
381
  }
239
382
  /**
240
- * Connect to brokers (for event subscription and/or broker protocol)
383
+ * Connect to all configured brokers (for event subscription and/or broker protocol)
384
+ * Always connects to ALL brokers defined in this.brokers for maximum redundancy
241
385
  */
242
- async connectToBrokers(urls) {
386
+ async connectToBrokers() {
387
+ this.logger.debug('connectToBrokers', 'Entering broker connection process');
388
+ // Check if already connected to avoid duplicate connections
389
+ if (this._brokerConnections.length > 0) {
390
+ this.logger.debug('connectToBrokers', 'Already connected to brokers, skipping');
391
+ return;
392
+ }
243
393
  // Allow any protocol to connect to brokers for event subscription
244
394
  // The protocol only determines how this client itself serves, not what it can connect to
245
- const brokerUrls = urls || [
246
- process.env.KADI_BROKER_URL || 'ws://localhost:8080'
247
- ];
248
- this.logger.info('connectToBrokers', `Connecting to ${brokerUrls.length} broker(s)`);
249
- for (const url of brokerUrls) {
250
- await this.connectToBroker(url);
395
+ const brokerNames = Object.keys(this.brokers);
396
+ const brokerUrls = Object.values(this.brokers);
397
+ if (brokerUrls.length === 0) {
398
+ const fallbackUrl = process.env.KADI_BROKER_URL || 'ws://localhost:8080';
399
+ this.logger.warn('connectToBrokers', `No brokers configured, using fallback: default -> ${fallbackUrl}`);
400
+ await this.connectToBroker(fallbackUrl, 'default');
401
+ this._isConnected = true;
402
+ this.emit('connected');
403
+ this.logger.debug('connectToBrokers', 'Exiting broker connection process with fallback');
404
+ return;
405
+ }
406
+ this.logger.info('connectToBrokers', `Connecting to ${brokerUrls.length} configured broker(s): ${brokerNames.join(', ')}`);
407
+ const connectionPromises = brokerNames.map(async (brokerName, index) => {
408
+ const url = brokerUrls[index];
409
+ try {
410
+ await this.connectToBroker(url, brokerName);
411
+ this.logger.info('connectToBrokers', `✅ Successfully connected to broker: ${brokerName} (${url})`);
412
+ return { brokerName, url, success: true, error: null };
413
+ }
414
+ catch (error) {
415
+ const errorMessage = error instanceof Error ? error.message : String(error);
416
+ this.logger.warn('connectToBrokers', `❌ Failed to connect to broker ${brokerName} (${url}): ${errorMessage}`);
417
+ return { brokerName, url, success: false, error: errorMessage };
418
+ }
419
+ });
420
+ const results = await Promise.allSettled(connectionPromises);
421
+ const connectionResults = results.map((result, index) => result.status === 'fulfilled'
422
+ ? result.value
423
+ : { url: brokerUrls[index], success: false, error: 'Promise rejected' });
424
+ const successfulConnections = connectionResults.filter((result) => result.success);
425
+ const failedConnections = connectionResults.filter((result) => !result.success);
426
+ if (successfulConnections.length === 0) {
427
+ const errorMessage = `Failed to connect to any brokers. Attempted: ${brokerUrls.join(', ')}`;
428
+ this.logger.error('connectToBrokers', errorMessage);
429
+ throw new Error(errorMessage);
430
+ }
431
+ this.logger.info('connectToBrokers', `Connected to ${successfulConnections.length}/${brokerUrls.length} brokers`);
432
+ if (failedConnections.length > 0) {
433
+ this.logger.warn('connectToBrokers', `Some brokers failed to connect: ${failedConnections.map((f) => f.url).join(', ')}`);
251
434
  }
252
435
  this._isConnected = true;
253
436
  this.emit('connected');
254
437
  }
438
+ /**
439
+ * Check if connected to a specific broker URL
440
+ */
441
+ isConnectedToBroker(url) {
442
+ return this._brokerConnections.some((conn) => conn.url === url);
443
+ }
255
444
  /**
256
445
  * Connect to a single broker
257
446
  */
258
- async connectToBroker(url) {
259
- this.logger.info('connectToBroker', `Connecting to broker at ${url}`);
447
+ async connectToBroker(url, brokerName) {
448
+ const displayName = brokerName ? `${brokerName} (${url})` : url;
449
+ this.logger.info('connectToBroker', `Connecting to broker: ${displayName}`);
260
450
  const ws = new WebSocket(url);
261
451
  const connection = {
262
452
  url,
453
+ brokerName,
263
454
  ws,
264
455
  isAuthenticated: false,
265
456
  agentId: '' // Use agentId instead of sessionId
@@ -290,9 +481,10 @@ export class KadiClient extends EventEmitter {
290
481
  this.handleBrokerMessage(connection, data.toString());
291
482
  });
292
483
  ws.on('close', () => {
293
- this.logger.info('connectToBroker', `Disconnected from ${url}`);
484
+ this.logger.info('connectToBroker', `WebSocket close event for ${brokerName} (${url})`);
294
485
  // Clean up heartbeat timer
295
486
  if (connection.heartbeatTimer) {
487
+ this.logger.debug('connectToBroker', `Cleaning up heartbeat timer in close event for ${brokerName}`);
296
488
  clearInterval(connection.heartbeatTimer);
297
489
  connection.heartbeatTimer = undefined;
298
490
  this.logger.debug('connectToBroker', 'Heartbeat timer cleaned up');
@@ -414,11 +606,9 @@ export class KadiClient extends EventEmitter {
414
606
  sendRequest(connection, message) {
415
607
  return new Promise((resolve, reject) => {
416
608
  const id = message.id;
417
- // ! DO NOT USE CONSOLE.LOG use the the logger
418
- console.log(`[KadiClient] 📤 SENDING REQUEST:`, JSON.stringify(message, null, 2));
609
+ this.logger.trace('sendBrokerRequest', `Sending ${message.method} request (ID: ${id})`);
419
610
  const timeout = setTimeout(() => {
420
- // ! DO NOT USE CONSOLE.LOG use the the logger
421
- console.log(`[KadiClient] ⏰ TIMEOUT for request ${id} (${message.method})`);
611
+ this.logger.warn('sendBrokerRequest', `Request timeout for ${message.method} (ID: ${id})`);
422
612
  this._pendingResponses.delete(id);
423
613
  reject(new Error(`Request timeout: ${message.method}`));
424
614
  }, 30000);
@@ -427,8 +617,7 @@ export class KadiClient extends EventEmitter {
427
617
  reject,
428
618
  timer: timeout
429
619
  });
430
- // ! DO NOT USE CONSOLE.LOG use the the logger
431
- console.log(`[KadiClient] 📝 STORED pending response for ID: ${id}`);
620
+ this.logger.trace('sendBrokerRequest', `Stored pending response for ${message.method} (ID: ${id})`);
432
621
  connection.ws.send(JSON.stringify(message));
433
622
  });
434
623
  }
@@ -533,9 +722,9 @@ export class KadiClient extends EventEmitter {
533
722
  return;
534
723
  }
535
724
  try {
536
- console.log(`[KadiClient] 🔧 EXECUTING TOOL: ${toolName} with params:`, toolInput);
725
+ this.logger.debug('handleToolInvocation', `Executing tool: ${toolName}`);
537
726
  const result = await method.handler(toolInput);
538
- console.log(`[KadiClient] ✅ TOOL RESULT for ${toolName}:`, result);
727
+ this.logger.debug('handleToolInvocation', `Tool ${toolName} completed successfully`);
539
728
  // Send kadi.ability.result with success result
540
729
  const resultMessage = {
541
730
  jsonrpc: '2.0',
@@ -546,11 +735,11 @@ export class KadiClient extends EventEmitter {
546
735
  result
547
736
  }
548
737
  };
549
- console.log(`[KadiClient] 📤 SENDING RESULT:`, JSON.stringify(resultMessage, null, 2));
738
+ this.logger.trace('handleToolInvocation', `Sending result for tool: ${toolName}`);
550
739
  connection.ws.send(JSON.stringify(resultMessage));
551
740
  }
552
741
  catch (error) {
553
- console.log(`[KadiClient] ❌ TOOL ERROR for ${toolName}:`, error);
742
+ this.logger.error('handleToolInvocation', `Tool ${toolName} failed: ${error.message}`);
554
743
  // Send kadi.ability.result with error
555
744
  const errorMessage = {
556
745
  jsonrpc: '2.0',
@@ -619,7 +808,7 @@ export class KadiClient extends EventEmitter {
619
808
  name: this.name,
620
809
  version: this.version,
621
810
  description: this.description,
622
- scope: this.scope
811
+ network: this.network
623
812
  });
624
813
  }
625
814
  }
@@ -632,8 +821,15 @@ export class KadiClient extends EventEmitter {
632
821
  protocol: this.protocol,
633
822
  tools: this.getToolNames()
634
823
  });
635
- // Keep process alive
636
- return new Promise(() => { });
824
+ // Keep process alive with a cancelable promise.
825
+ // When abilities are loaded natively, they still call serve() which
826
+ // creates this promise. We store the resolver so disconnect() can
827
+ // resolve it later, allowing the process to exit cleanly.
828
+ // Without this, the unresolved promise would keep the Node.js event
829
+ // loop running forever, preventing graceful shutdown.
830
+ return new Promise((resolve) => {
831
+ this._nativeServePromiseResolve = resolve;
832
+ });
637
833
  case 'stdio':
638
834
  // For stdio serving, delegate to StdioTransport's serve method
639
835
  // This handles JSON-RPC over stdin/stdout when running as a child process
@@ -729,7 +925,7 @@ export class KadiClient extends EventEmitter {
729
925
  * @returns The result from the remote tool invocation
730
926
  */
731
927
  async callTool(targetAgent, toolName, params) {
732
- if (this.protocol !== 'broker' || this._brokerConnections.length === 0) {
928
+ if (this._brokerConnections.length === 0) {
733
929
  throw new Error('Must be connected to broker to call remote tools');
734
930
  }
735
931
  const broker = this._brokerConnections[0];
@@ -768,10 +964,130 @@ export class KadiClient extends EventEmitter {
768
964
  }
769
965
  }
770
966
  /**
771
- * Load an external ability
967
+ * Send a request directly to the broker
968
+ * Uses the current broker or the specified broker
969
+ *
970
+ * @param method The RPC method to call (e.g., 'kadi.ability.list')
971
+ * @param params The parameters for the method
972
+ * @param brokerName Optional broker name to use (overrides current broker)
973
+ * @returns The response from the broker
974
+ */
975
+ async sendBrokerRequest(method, params, brokerName) {
976
+ // If specific broker requested, temporarily use it
977
+ const originalBroker = this._currentBroker;
978
+ if (brokerName) {
979
+ this.setCurrentBroker(brokerName);
980
+ }
981
+ try {
982
+ const connection = this.getCurrentBrokerConnection();
983
+ const request = {
984
+ jsonrpc: '2.0',
985
+ method,
986
+ params,
987
+ id: this._idFactory.next()
988
+ };
989
+ const response = await this.sendRequest(connection, request);
990
+ if ('error' in response && response.error) {
991
+ throw new Error(`Broker request failed: ${response.error.message || 'Unknown error'}`);
992
+ }
993
+ return response.result;
994
+ }
995
+ finally {
996
+ // Restore original broker if we changed it
997
+ if (brokerName && originalBroker) {
998
+ this._currentBroker = originalBroker;
999
+ }
1000
+ }
1001
+ }
1002
+ /**
1003
+ * Load an external ability and make its methods available for calling
1004
+ *
1005
+ * This is the KadiClient's convenient wrapper for loading abilities. It handles
1006
+ * broker resolution from your client config and delegates the actual loading
1007
+ * to the standalone loadAbility() function.
1008
+ *
1009
+ * Note: This method delegates to the standalone loadAbility() function after
1010
+ * resolving broker configurations. If you need more control or don't have a
1011
+ * KadiClient instance, you can use the standalone loadAbility() function directly.
1012
+ *
1013
+ * @param nameOrPath - Which ability to load. Can be:
1014
+ * - "ability-name" - loads from your installed abilities
1015
+ * - "/path/to/ability" - loads from a folder path
1016
+ *
1017
+ * @param protocol - How to connect to the ability:
1018
+ * - 'native': Load directly into this process (fastest, same language only)
1019
+ * - 'stdio': Spawn as child process, communicate via stdin/stdout (any language)
1020
+ * - 'broker': Connect via broker (ability runs anywhere, most flexible)
1021
+ *
1022
+ * @param options - Configuration for how to load the ability:
1023
+ *
1024
+ * broker: Which named broker to use (from your KadiClient config). Only for 'broker' protocol.
1025
+ * Example: 'prod', 'local', 'staging'
1026
+ * Uses your current/default broker if not specified.
1027
+ *
1028
+ * brokerUrl: Direct broker WebSocket URL. Only for 'broker' protocol.
1029
+ * Example: 'ws://localhost:8080', 'wss://broker.company.com'
1030
+ * Overrides the 'broker' option if provided.
1031
+ *
1032
+ * spawnAbility: Whether to start the ability first. Only for 'broker' protocol.
1033
+ * - true: Start the ability process AND connect to it (useful for development)
1034
+ * - false (default): Just connect to an already-running ability
1035
+ * Most production setups use false since abilities run independently.
1036
+ *
1037
+ * networks: Which KADI networks to search for the ability. Only for 'broker' protocol.
1038
+ * Example: ['global', 'team-alpha', 'dev-environment']
1039
+ * search specific networks to find them. Defaults to ['global'] if not specified.
1040
+ *
1041
+ * @returns A proxy object that lets you call the ability's methods directly.
1042
+ * Example: ability.processData({input: "hello"}) calls the ability's processData method.
1043
+ *
1044
+ * @example
1045
+ * // Load a JavaScript ability in the same process (fastest)
1046
+ * const mathLib = await client.loadAbility('math-utils', 'native');
1047
+ * const result = await mathLib.add({a: 5, b: 3}); // Returns 8
1048
+ *
1049
+ * @example
1050
+ * // Load a Go/Python/Rust ability via child process
1051
+ * const imageProcessor = await client.loadAbility('image-resizer', 'stdio');
1052
+ * const thumbnail = await imageProcessor.resize({image: buffer, size: '100x100'});
1053
+ *
1054
+ * @example
1055
+ * // Connect to an ability running on a remote broker (using named broker from config)
1056
+ * const aiService = await client.loadAbility('gpt-analyzer', 'broker', {
1057
+ * broker: 'prod', // Use the 'prod' broker from KadiClient config
1058
+ * networks: ['ai-services', 'global'] // Look in these networks
1059
+ * });
1060
+ * const analysis = await aiService.analyze({text: "Hello world"});
772
1061
  */
773
1062
  async loadAbility(nameOrPath, protocol = 'native', options = {}) {
774
- const ability = await loadAbility(nameOrPath, protocol, options);
1063
+ // For broker protocol, resolve broker name to URL
1064
+ let resolvedOptions = { ...options };
1065
+ if (protocol === 'broker') {
1066
+ // If brokerUrl not provided, resolve from broker name or use current/default
1067
+ if (!options.brokerUrl) {
1068
+ const brokerName = options.broker || this._currentBroker || this.defaultBroker;
1069
+ if (!brokerName) {
1070
+ throw new Error(`No broker specified. Either provide 'broker' option or set a default broker.`);
1071
+ }
1072
+ if (!this.brokers[brokerName]) {
1073
+ const availableBrokers = Object.keys(this.brokers);
1074
+ throw new Error(`Broker '${brokerName}' not found in configuration.\n` +
1075
+ `Available brokers: ${availableBrokers.join(', ') || 'none'}\n` +
1076
+ `Add broker to KadiClient config or agent.json.`);
1077
+ }
1078
+ // If a specific broker was requested, update current broker
1079
+ if (options.broker) {
1080
+ this.setCurrentBroker(brokerName);
1081
+ }
1082
+ resolvedOptions.brokerUrl = this.brokers[brokerName];
1083
+ this.logger.debug('loadAbility', `Resolved broker '${brokerName}' to URL: ${resolvedOptions.brokerUrl}`);
1084
+ }
1085
+ }
1086
+ // Pass the client instance for broker protocol to reuse connection
1087
+ const optionsWithClient = protocol === 'broker'
1088
+ ? { ...resolvedOptions, existingClient: this }
1089
+ : resolvedOptions;
1090
+ const ability = await loadAbility(nameOrPath, protocol, optionsWithClient);
775
1091
  this._abilities.set(ability.name, ability);
776
1092
  // For native/stdio protocols, connect ability events to this client's event system
777
1093
  if (protocol === 'native' || protocol === 'stdio') {
@@ -804,21 +1120,40 @@ export class KadiClient extends EventEmitter {
804
1120
  return ability;
805
1121
  }
806
1122
  /**
807
- * Disconnect from brokers
1123
+ * Disconnect from brokers and cleanup resources
808
1124
  */
809
1125
  async disconnect() {
1126
+ this.logger.info('disconnect', `Starting disconnect for ${this.name}`);
1127
+ // Resolve the native serve promise if it exists
1128
+ // This is critical for native protocol abilities to shut down cleanly.
1129
+ // When an ability calls serve() in native mode, it creates a promise
1130
+ // that keeps the process alive. By resolving it here, we allow the
1131
+ // Node.js event loop to exit naturally.
1132
+ if (this._nativeServePromiseResolve) {
1133
+ this.logger.debug('disconnect', 'Resolving native serve promise to allow clean shutdown');
1134
+ this._nativeServePromiseResolve();
1135
+ this._nativeServePromiseResolve = undefined;
1136
+ }
1137
+ // Disconnect from all broker connections (if any)
1138
+ this.logger.info('disconnect', `Disconnecting from ${this._brokerConnections.length} broker connections`);
810
1139
  for (const connection of this._brokerConnections) {
811
1140
  // Clean up heartbeat timer before closing
812
1141
  if (connection.heartbeatTimer) {
1142
+ this.logger.debug('disconnect', `Clearing heartbeat timer for ${connection.brokerName}`);
813
1143
  clearInterval(connection.heartbeatTimer);
814
1144
  connection.heartbeatTimer = undefined;
815
1145
  }
816
1146
  if (connection.ws) {
1147
+ this.logger.debug('disconnect', `Closing WebSocket for ${connection.brokerName}`);
817
1148
  connection.ws.close();
818
1149
  }
819
1150
  }
820
1151
  this._brokerConnections.length = 0;
821
1152
  this._isConnected = false;
1153
+ // Remove all event listeners
1154
+ this.logger.debug('disconnect', 'Removing all internal event listeners');
1155
+ this.removeAllListeners();
1156
+ this.logger.info('disconnect', `Disconnect complete for ${this.name}`);
822
1157
  this.emit('disconnected');
823
1158
  }
824
1159
  /**
@@ -836,6 +1171,7 @@ export class KadiClient extends EventEmitter {
836
1171
  }
837
1172
  /**
838
1173
  * Find agent.json file
1174
+ * TODO: Not sure, but maybe this function can be moved to pathUtils.ts?
839
1175
  */
840
1176
  async _findAgentJson() {
841
1177
  const possiblePaths = [
@@ -971,9 +1307,13 @@ export class KadiClient extends EventEmitter {
971
1307
  this.on(ABILITY_EVENT_TRANSPORT, (envelope) => {
972
1308
  this.logger.trace('subscribeToEvent', `Received ${ABILITY_EVENT_TRANSPORT}: ${envelope.eventName}`);
973
1309
  // Check all registered patterns to see which subscriptions match this event
1310
+ // _eventSubscriptions is a Map like: {'user.login' => [callbacks], 'payment.*' => [callbacks]}
1311
+ // We loop through each pattern to see if the incoming event matches
974
1312
  for (const [registeredPattern] of this._eventSubscriptions) {
1313
+ // Example: If event is 'user.login' and pattern is 'user.*', this matches!
975
1314
  if (this._matchesPattern(envelope.eventName, registeredPattern)) {
976
1315
  this.logger.debug('subscribeToEvent', `Pattern matched (${envelope.eventName} vs ${registeredPattern}), dispatching event`);
1316
+ // Found a match! Call all callbacks registered for this pattern
977
1317
  this._dispatchEvent(registeredPattern, envelope.data);
978
1318
  }
979
1319
  }
@@ -1002,8 +1342,11 @@ export class KadiClient extends EventEmitter {
1002
1342
  /**
1003
1343
  * Subscribe to broker events for this specific pattern.
1004
1344
  * Broker subscriptions are per-pattern, unlike the transport listeners above.
1345
+ * Only connect to broker if we're actually using the broker protocol.
1005
1346
  */
1006
- if (this.brokerUrls && this.brokerUrls.length > 0) {
1347
+ if (this.protocol === 'broker' &&
1348
+ this.brokers &&
1349
+ Object.keys(this.brokers).length > 0) {
1007
1350
  if (this._brokerConnections.length === 0) {
1008
1351
  this.logger.debug('subscribeToEvent', 'Connecting to broker for event subscription');
1009
1352
  this.connectToBrokers()
@@ -1127,21 +1470,34 @@ export class KadiClient extends EventEmitter {
1127
1470
  return false;
1128
1471
  }
1129
1472
  /**
1130
- * Dispatch event to subscribers
1131
- * TODO: I do not understand this function
1473
+ * Dispatch event to subscribers - This is the final delivery step
1474
+ *
1475
+ * After all the routing through transports and pattern matching,
1476
+ * this function actually calls the user's callback functions with the event data.
1477
+ *
1478
+ * Example: If user did `client.subscribeToEvent('user.login', myFunction)`
1479
+ * then when 'user.login' event arrives, this calls `myFunction(eventData)`
1132
1480
  *
1133
1481
  * @private
1134
- * @param pattern Pattern that matched
1135
- * @param data Event data
1482
+ * @param pattern Pattern that matched (e.g., 'user.login' or 'payment.*')
1483
+ * @param data Event data to pass to the callbacks
1136
1484
  */
1137
1485
  _dispatchEvent(pattern, data) {
1486
+ // Get all callback functions registered for this pattern
1487
+ // Example: If pattern is 'user.login', this gets [callback1, callback2] array
1138
1488
  const callbacks = this._eventSubscriptions.get(pattern);
1139
1489
  if (callbacks) {
1490
+ // Call each registered callback with the event data
1491
+ // If multiple functions subscribed to same pattern, all get called
1140
1492
  callbacks.forEach((callback) => {
1141
1493
  try {
1494
+ // This is THE moment the user's function gets called!
1495
+ // Example: callback(data) might be userFunction({userId: 123, timestamp: ...})
1142
1496
  callback(data);
1143
1497
  }
1144
1498
  catch (error) {
1499
+ // If user's callback throws error, log it but continue with other callbacks
1500
+ // This prevents one bad callback from breaking all event delivery
1145
1501
  this.logger.error('_dispatchEvent', `Error in event callback for ${pattern}:`, error);
1146
1502
  }
1147
1503
  });