@kadi.build/core 0.0.1-alpha.1 → 0.0.1-alpha.3

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.
Files changed (39) hide show
  1. package/README.md +1145 -216
  2. package/examples/example-abilities/echo-js/README.md +131 -0
  3. package/examples/example-abilities/echo-js/agent.json +63 -0
  4. package/examples/example-abilities/echo-js/package.json +24 -0
  5. package/examples/example-abilities/echo-js/service.js +43 -0
  6. package/examples/example-abilities/hash-go/agent.json +53 -0
  7. package/examples/example-abilities/hash-go/cmd/hash_ability/main.go +340 -0
  8. package/examples/example-abilities/hash-go/go.mod +3 -0
  9. package/examples/example-agent/abilities/echo-js/0.0.1/README.md +131 -0
  10. package/examples/example-agent/abilities/echo-js/0.0.1/agent.json +63 -0
  11. package/examples/example-agent/abilities/echo-js/0.0.1/package-lock.json +93 -0
  12. package/examples/example-agent/abilities/echo-js/0.0.1/package.json +24 -0
  13. package/examples/example-agent/abilities/echo-js/0.0.1/service.js +41 -0
  14. package/examples/example-agent/abilities/hash-go/0.0.1/agent.json +53 -0
  15. package/examples/example-agent/abilities/hash-go/0.0.1/bin/hash_ability +0 -0
  16. package/examples/example-agent/abilities/hash-go/0.0.1/cmd/hash_ability/main.go +340 -0
  17. package/examples/example-agent/abilities/hash-go/0.0.1/go.mod +3 -0
  18. package/examples/example-agent/agent.json +39 -0
  19. package/examples/example-agent/index.js +102 -0
  20. package/examples/example-agent/package-lock.json +93 -0
  21. package/examples/example-agent/package.json +17 -0
  22. package/package.json +4 -2
  23. package/src/KadiAbility.js +478 -0
  24. package/src/index.js +65 -0
  25. package/src/loadAbility.js +1086 -0
  26. package/src/servers/BaseRpcServer.js +404 -0
  27. package/src/servers/BrokerRpcServer.js +776 -0
  28. package/src/servers/StdioRpcServer.js +360 -0
  29. package/src/transport/BrokerMessageBuilder.js +377 -0
  30. package/src/transport/IpcMessageBuilder.js +1229 -0
  31. package/src/utils/agentUtils.js +137 -0
  32. package/src/utils/commandUtils.js +64 -0
  33. package/src/utils/configUtils.js +72 -0
  34. package/src/utils/logger.js +161 -0
  35. package/src/utils/pathUtils.js +86 -0
  36. package/broker.js +0 -214
  37. package/index.js +0 -370
  38. package/ipc.js +0 -220
  39. package/ipcInterfaces/pythonAbilityIPC.py +0 -177
@@ -0,0 +1,1086 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+ import { spawn } from 'child_process';
4
+ import { WebSocket } from 'ws';
5
+ import { generateKeyPairSync, randomUUID } from 'crypto';
6
+ import { fileURLToPath } from 'url';
7
+ import EventEmitter from 'events';
8
+
9
+ import { getProjectJSON, getAbilityJSON } from './utils/agentUtils.js';
10
+ import { rootDir } from './utils/pathUtils.js';
11
+ import { StdioFrameReader, Ipc } from './transport/IpcMessageBuilder.js';
12
+ import {
13
+ Broker,
14
+ IdFactory,
15
+ toBase64Der
16
+ } from './transport/BrokerMessageBuilder.js';
17
+
18
+ import { createComponentLogger, formatObject } from './utils/logger.js';
19
+ import { FRAME_HEADERS, FRAME_VALUES } from './transport/IpcMessageBuilder.js';
20
+
21
+ const logger = createComponentLogger('loadAbility');
22
+
23
+ const __filename = fileURLToPath(import.meta.url);
24
+
25
+ // ===== ability loader cache (avoid re-spawning on repeated loads) =====
26
+ // key: `${name}@${version}:protocol` -> proxy/module
27
+ const __abilityCache = new Map();
28
+
29
+ /**
30
+ * Load an ability by name and return a uniform interface (module or proxy).
31
+ *
32
+ * Supports three protocols via an optional manifest at:
33
+ * abilities/<name>/<version>/ability.json
34
+ *
35
+ * - protocol: "native" -> in-process import
36
+ * - protocol: "stdio" -> spawn local process, JSON-RPC over stdio
37
+ * - protocol: "broker" -> request/response via the KADI Broker
38
+ */
39
+ export async function loadAbility(abilityName, protocol) {
40
+ logger.lifecycle('start', `Loading ability: ${abilityName}`);
41
+ logger.info('loadAbility', `Protocol: ${protocol || 'auto'}`);
42
+
43
+ // ------------------------------
44
+ // 1) Resolve version to load (project or nested ability context)
45
+ // ------------------------------
46
+ let agentJSON = await getProjectJSON();
47
+ let abilityVersion = agentJSON?.abilities?.[abilityName] || null;
48
+
49
+ if (!abilityVersion) {
50
+ // Fallback when loadAbility() is called from inside an ability directory
51
+ logger.warn(
52
+ 'config',
53
+ `Ability ${abilityName} not found in Project configuration`
54
+ );
55
+
56
+ // Identify parent ability name/version from current file path
57
+ const filePath = __filename;
58
+ const dirPath = path.dirname(filePath);
59
+ const pathParts = dirPath.split(path.sep);
60
+ const abilitiesIndex = pathParts.indexOf('abilities');
61
+
62
+ if (abilitiesIndex !== -1 && pathParts.length > abilitiesIndex + 2) {
63
+ const parentAbility = pathParts[abilitiesIndex + 1];
64
+ const parentVersion = pathParts[abilitiesIndex + 2];
65
+
66
+ logger.info(
67
+ 'context',
68
+ `Loading from nested ability context: ${parentAbility}:${parentVersion}`
69
+ );
70
+
71
+ agentJSON = await getAbilityJSON(parentAbility, parentVersion);
72
+ abilityVersion = agentJSON?.abilities?.[abilityName] || null;
73
+
74
+ if (!abilityVersion) {
75
+ logger.error(
76
+ 'config',
77
+ `Ability ${abilityName} not found in ${parentAbility}:${parentVersion} configuration`
78
+ );
79
+
80
+ throw new Error(
81
+ `Ability ${abilityName} not found in ${parentAbility}:${parentVersion} configuration`
82
+ );
83
+ }
84
+ } else {
85
+ logError('Ability not found in Project agent.json');
86
+ throw new Error('Ability not found in Project agent.json');
87
+ }
88
+ }
89
+
90
+ const abilityDir = path.join(
91
+ rootDir,
92
+ 'abilities',
93
+ abilityName,
94
+ abilityVersion
95
+ );
96
+ logger.success(
97
+ 'version',
98
+ `Resolved ${abilityName} version: ${abilityVersion}`
99
+ );
100
+ logger.trace('paths', `Ability directory: ${abilityDir}`);
101
+
102
+ // Check if ability directory exists
103
+ if (!fs.existsSync(abilityDir)) {
104
+ throw new Error(
105
+ `Ability '${abilityName}' version '${abilityVersion}' is not installed.\n` +
106
+ `Expected location: ${abilityDir}\n` +
107
+ `Please install the ability using: kadi install`
108
+ );
109
+ }
110
+
111
+ // ------------------------------
112
+ // 2) Read ability manifest (optional). If missing -> default to node-module
113
+ // ------------------------------
114
+ const manifestPath = path.join(abilityDir, 'agent.json');
115
+
116
+ logger.trace('manifest', `Looking for agent.json at: ${manifestPath}`);
117
+
118
+ const manifest = fs.existsSync(manifestPath)
119
+ ? JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
120
+ : null;
121
+
122
+ logger.info('manifest', `Agent.json found: ${manifest ? 'yes' : 'no'}`);
123
+
124
+ if (!protocol) {
125
+ const firstKey = Object.keys(manifest?.interfaces)[0];
126
+ protocol = firstKey;
127
+ logger.info('protocol', `Auto-selected protocol: ${protocol}`);
128
+ } else {
129
+ protocol = protocol.toLowerCase();
130
+ logger.info('protocol', `Using specified protocol: ${protocol}`);
131
+ }
132
+
133
+ // Check cache with protocol-specific key
134
+ const cacheKey = `${abilityName}@${abilityVersion}:${protocol}`;
135
+ if (__abilityCache.has(cacheKey)) {
136
+ logger.info('cache', `Using cached ability: ${cacheKey}`);
137
+ return __abilityCache.get(cacheKey);
138
+ }
139
+
140
+ const startCmd = manifest?.scripts?.start || null;
141
+ if (!startCmd) {
142
+ logger.error('config', `Missing start command for ${abilityName}`);
143
+ throw new Error(
144
+ `Ability ${abilityName} missing start command (scripts.start)`
145
+ );
146
+ }
147
+
148
+ logger.trace('command', `Start command: ${startCmd}`);
149
+
150
+ // ------------------------------
151
+ // 3) Dispatch by protocol
152
+ // ------------------------------
153
+ let loaded;
154
+
155
+ if (protocol === 'native') {
156
+ logger.info('native', `Loading ${abilityName} as native JavaScript module`);
157
+
158
+ const entryFromManifest = manifest?.interfaces?.native?.entry;
159
+ let entryRelPath = entryFromManifest;
160
+
161
+ if (!entryRelPath) {
162
+ const moduleJSON = getAbilityJSON(abilityName, abilityVersion);
163
+ entryRelPath = moduleJSON.entry;
164
+ }
165
+
166
+ if (!entryRelPath) {
167
+ throw new Error(
168
+ `Ability '${abilityName}' is missing entry point configuration.\n` +
169
+ `Please specify 'entry' in the ability's agent.json or 'interface.entry' in the manifest.`
170
+ );
171
+ }
172
+
173
+ const modulePath = path.join(abilityDir, entryRelPath);
174
+
175
+ if (!fs.existsSync(modulePath)) {
176
+ throw new Error(
177
+ `Ability '${abilityName}' entry file not found: ${modulePath}\n` +
178
+ `Expected entry point: ${entryRelPath}`
179
+ );
180
+ }
181
+
182
+ console.error(`Loading module from: ${modulePath}`);
183
+ // const mod = await import(modulePath + `?v=${Date.now()}`);
184
+ const mod = await import(modulePath);
185
+ const rawModule = mod.default || mod;
186
+
187
+ // Detect "ability export" shape
188
+ const isAbilityExport =
189
+ rawModule &&
190
+ typeof rawModule.on === 'function' && // EventEmitter
191
+ typeof rawModule.publishEvent === 'function' &&
192
+ rawModule.methodHandlers instanceof Map;
193
+
194
+ const eventEmitter = new EventEmitter();
195
+
196
+ let functions = {};
197
+ let call; // (method: string, params: any) => Promise<any>
198
+
199
+ if (isAbilityExport) {
200
+ // 1) expose registered methods
201
+ for (const name of rawModule.methodHandlers.keys()) {
202
+ // value doesn't matter for buildAbilityProxy if it only
203
+ // needs the keys,
204
+ // but giving a callable makes it future-proof.
205
+ functions[name] = async (params) => {
206
+ const handler = rawModule.methodHandlers.get(name);
207
+ return await handler(params);
208
+ };
209
+ }
210
+
211
+ // 2) route calls through the handlers map
212
+ call = async (method, params) => {
213
+ const handler = rawModule.methodHandlers.get(method);
214
+ if (typeof handler !== 'function') {
215
+ throw new Error(
216
+ `Method '${method}' is not registered on KadiAbility`
217
+ );
218
+ }
219
+ return await handler(params);
220
+ };
221
+ } else {
222
+ // existing behavior for plain function modules
223
+ for (const [key, value] of Object.entries(rawModule)) {
224
+ if (typeof value === 'function' && !key.startsWith('_')) {
225
+ functions[key] = value;
226
+ }
227
+ }
228
+ call = async (method, params) => {
229
+ const func = rawModule[method];
230
+ if (typeof func !== 'function') {
231
+ throw new Error(
232
+ `Method '${method}' is not a function or doesn't exist`
233
+ );
234
+ }
235
+ return await func(params);
236
+ };
237
+ }
238
+
239
+ // Create uniform proxy
240
+ loaded = buildAbilityProxy({
241
+ call,
242
+ functions,
243
+ eventEmitter
244
+ });
245
+
246
+ // Wire native events (works for the KadiAbility instance)
247
+ setupNativeEventListener(rawModule, eventEmitter);
248
+ } else if (protocol === 'stdio') {
249
+ logger.info(
250
+ 'stdio',
251
+ `Loading ${abilityName} with stdio JSON-RPC transport`
252
+ );
253
+
254
+ const timeoutMs = manifest?.interface?.timeoutMs ?? 15000;
255
+ const env = { ...process.env, KADI_PROTOCOL: protocol };
256
+ const logFile = path.join(abilityDir, `${abilityName}.log`);
257
+
258
+ // Use the enhanced spawnJSONRPCProcess that supports events
259
+ const rpc = spawnJSONRPCProcess({
260
+ command: startCmd,
261
+ cwd: abilityDir,
262
+ env,
263
+ timeoutMs,
264
+ logFile
265
+ });
266
+
267
+ // Create IPC helper bound to this rpc instance
268
+ const IPC = Ipc.with(rpc);
269
+
270
+ // Handshake: init
271
+ const initRest = await IPC.init({ api: '1.0' });
272
+
273
+ // Get static contract from the agent.json (if any)
274
+ let staticFunctions = null;
275
+ if (Array.isArray(manifest?.exports)) {
276
+ const tools = manifest.exports;
277
+ staticFunctions = Object.fromEntries(tools.map((t) => [t.name]));
278
+ }
279
+
280
+ // Always call discovery when enabled to get dynamic methods
281
+ let discoveredFunctions = null;
282
+ if (manifest?.interface?.discover !== false) {
283
+ try {
284
+ const disc = await IPC.discover();
285
+ discoveredFunctions = disc?.functions || null;
286
+ } catch (error) {
287
+ console.warn(`Discovery failed for ${abilityName}: ${error.message}`);
288
+ discoveredFunctions = null;
289
+ }
290
+ }
291
+
292
+ // Merge static and discovered functions
293
+ let functions = { ...staticFunctions };
294
+ if (discoveredFunctions) {
295
+ functions = { ...functions, ...discoveredFunctions };
296
+ }
297
+
298
+ // Create EventEmitter for events
299
+ const eventEmitter = new EventEmitter();
300
+
301
+ // Set up event listener for __kadi_event notifications
302
+ setupStdioEventListener(rpc, eventEmitter);
303
+
304
+ // Create proxy with event support
305
+ loaded = buildAbilityProxy({
306
+ call: (method, params) => IPC.call(method, params),
307
+ functions,
308
+ eventEmitter
309
+ });
310
+ } else if (protocol === 'broker') {
311
+ logger.info('broker', `Loading ${abilityName} with broker transport`);
312
+
313
+ const timeoutMs = manifest?.interface?.timeoutMs ?? 10000;
314
+ const scope = randomUUID();
315
+ logger.trace('broker', `Generated agent scope: ${scope}`);
316
+
317
+ const brokerUrl =
318
+ manifest?.brokers?.local ||
319
+ manifest?.brokers?.remote ||
320
+ (await getProjectJSON())?.brokers?.local;
321
+
322
+ logger.info('broker', `Broker URL: ${brokerUrl}`);
323
+
324
+ const serviceName =
325
+ manifest?.interface?.serviceName ||
326
+ `ability.${abilityName}.${abilityVersion.replace(/\./g, '_')}`;
327
+
328
+ if (startCmd) {
329
+ // Spawn the ability process
330
+ const env = {
331
+ ...process.env,
332
+ KADI_PROTOCOL: protocol,
333
+ KADI_BROKER_URL: brokerUrl,
334
+ KADI_SERVICE_NAME: serviceName,
335
+ KADI_AGENT_SCOPE: scope
336
+ };
337
+
338
+ logger.info('spawn', `Spawning ${abilityName} ability process`);
339
+
340
+ const child = spawn(startCmd, {
341
+ cwd: abilityDir,
342
+ env,
343
+ shell: true,
344
+ stdio: 'inherit',
345
+ detached: false
346
+ });
347
+
348
+ child.on('error', (error) => {
349
+ logger.error(
350
+ 'spawn',
351
+ `Failed to spawn ${abilityName}: ${error.message}`
352
+ );
353
+ });
354
+
355
+ child.on('exit', (code, signal) => {
356
+ if (code !== 0) {
357
+ logger.warn(
358
+ 'spawn',
359
+ `Ability process exited with code ${code}, signal ${signal}`
360
+ );
361
+ } else {
362
+ logger.success('spawn', 'Ability process exited cleanly');
363
+ }
364
+ });
365
+
366
+ // Give the ability time to connect to the broker
367
+ await new Promise((resolve) => setTimeout(resolve, 1000));
368
+ }
369
+
370
+ logger.info('broker', 'Creating broker RPC client');
371
+
372
+ let rpc;
373
+ try {
374
+ rpc = createBrokerRPCClient({
375
+ brokerUrl,
376
+ serviceName,
377
+ timeoutMs,
378
+ scope
379
+ });
380
+
381
+ await rpc.testConnection();
382
+ logger.success('broker', 'Agent connected to broker successfully');
383
+ } catch (error) {
384
+ logger.error(
385
+ 'broker',
386
+ `Agent failed to connect to broker: ${error.message}`
387
+ );
388
+ throw new Error(
389
+ `Failed to connect to KADI Broker at ${brokerUrl}\n` +
390
+ `Original error: ${error.message}\n` +
391
+ `\n` +
392
+ `Common solutions:\n` +
393
+ `• Make sure the KADI Broker is running\n` +
394
+ `• Check if the broker URL is correct in agent.json\n` +
395
+ `• Verify network connectivity to the broker\n` +
396
+ `• Ensure the broker is accepting connections`
397
+ );
398
+ }
399
+
400
+ // Get static contract from the agent.json (if any)
401
+ let staticFunctions = null;
402
+ if (Array.isArray(manifest?.exports)) {
403
+ const tools = manifest.exports;
404
+ staticFunctions = Object.fromEntries(tools.map((t) => [t.name]));
405
+ }
406
+
407
+ // Get the full method list from the broker
408
+ let brokerFunctions = null;
409
+ if (manifest?.interface?.discover !== false) {
410
+ try {
411
+ logger.trace(
412
+ 'discovery',
413
+ `Querying broker for methods with scopes: ${JSON.stringify([scope])}`
414
+ );
415
+
416
+ const brokerMethods = await rpc.getAvailableMethods([scope]);
417
+
418
+ logger.success(
419
+ 'discovery',
420
+ `Broker returned ${brokerMethods.tools?.length || 0} methods`
421
+ );
422
+
423
+ if (logger.enabled) {
424
+ brokerMethods.tools?.forEach((tool) => {
425
+ logger.trace('discovery', ` - ${tool.name}`);
426
+ });
427
+ }
428
+
429
+ brokerFunctions = Object.fromEntries(
430
+ brokerMethods.tools.map((t) => [t.name])
431
+ );
432
+ } catch (error) {
433
+ logger.warn(
434
+ 'discovery',
435
+ `Broker method discovery failed: ${error.message}`
436
+ );
437
+ brokerFunctions = null;
438
+ }
439
+ }
440
+
441
+ // Merge static and broker functions
442
+ let functions = { ...staticFunctions };
443
+ if (brokerFunctions) {
444
+ functions = { ...functions, ...brokerFunctions };
445
+ }
446
+
447
+ // Create EventEmitter for events
448
+ const eventEmitter = new EventEmitter();
449
+
450
+ // Set up event listener for kadi.event messages
451
+ setupBrokerEventListener(rpc, eventEmitter);
452
+
453
+ // Create proxy with event support
454
+ loaded = buildAbilityProxy({
455
+ call: (method, params) => rpc.call(method, params),
456
+ functions,
457
+ eventEmitter
458
+ });
459
+ } else {
460
+ throw new Error(`Unsupported ability interface protocol: ${protocol}`);
461
+ }
462
+
463
+ __abilityCache.set(cacheKey, loaded);
464
+
465
+ logger.success(
466
+ 'complete',
467
+ `Successfully loaded ${abilityName} with ${Object.keys(loaded.__list?.() || {}).length} methods`
468
+ );
469
+
470
+ return loaded;
471
+ }
472
+
473
+ /* =========================================================================
474
+ * Helpers: small, dependency-free shims to keep the loader readable.
475
+ * ======================================================================= */
476
+
477
+ /**
478
+ * Spawn a child process and speak newline-delimited JSON-RPC 2.0 over stdio.
479
+ * Enhanced to support event notifications via __kadi_event messages.
480
+ *
481
+ * Minimal reference implementation; production code should add heartbeats,
482
+ * backpressure control, and graceful shutdown handling.
483
+ * - write JSON-RPC requests to the child's stdin,
484
+ * - read/parse JSON-RPC responses from the child's stdout,
485
+ * - handle __kadi_event notifications for event publishing
486
+ * - enforce timeouts
487
+ * - logFile if present will see its content append with information echoed to stderr
488
+ *
489
+ * Reference: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/
490
+ */
491
+ function spawnJSONRPCProcess({ command, cwd, env, timeoutMs, logFile }) {
492
+ const ability = spawn(command, {
493
+ cwd,
494
+ env,
495
+ shell: true,
496
+ stdio: ['pipe', 'pipe', 'pipe'] // IMPORTANT: we need pipes, not 'inherit'
497
+ // 'pipe' for stdin, stdout, stderr
498
+ });
499
+
500
+ // ────────────────────────────────────
501
+ // Optional file logger for stderr
502
+ // ────────────────────────────────────
503
+ let logStream = null;
504
+ if (logFile) {
505
+ try {
506
+ fs.mkdirSync(path.dirname(logFile), { recursive: true });
507
+ logStream = fs.createWriteStream(logFile, { flags: 'a' });
508
+ } catch (e) {
509
+ console.error('Unable to open log file', logFile, e);
510
+ }
511
+ }
512
+
513
+ const pending = new Map(); // id -> { resolve, reject, timer }
514
+ let nextId = 1;
515
+
516
+ // Storage for event handlers
517
+ const eventHandlers = [];
518
+
519
+ // Create frame reader for parsing LSP-style messages from stdout
520
+ const frameReader = new StdioFrameReader(ability.stdout, {
521
+ maxBufferSize: 8 * 1024 * 1024 // 8MB buffer limit
522
+ });
523
+
524
+ // Set up frame processing with rate limiting to prevent event loop blocking
525
+ let processedFrames = 0;
526
+ const maxFramesPerChunk = 10; // Prevent infinite loops under extreme load
527
+ let pendingProcessing = false;
528
+
529
+ frameReader.onMessage((result) => {
530
+ // Rate limiting: if we've processed too many frames in this tick, defer to next tick
531
+ if (processedFrames >= maxFramesPerChunk && !pendingProcessing) {
532
+ pendingProcessing = true;
533
+ setImmediate(() => {
534
+ processedFrames = 0;
535
+ pendingProcessing = false;
536
+ // Process this result in the next tick
537
+ handleResult(result);
538
+ });
539
+ return;
540
+ }
541
+
542
+ handleResult(result);
543
+ processedFrames++;
544
+ });
545
+
546
+ function handleResult(result) {
547
+ if (!result.success) {
548
+ // Frame parsing failed - log detailed error and fail all pending calls
549
+ logger.error(`[rpc] Frame corruption detected: ${result.error}`);
550
+ logger.error(`[rpc] Corruption type: ${result.corruption_type}`);
551
+ logger.error(`[rpc] Details: ${result.message}`);
552
+
553
+ // Fail all pending calls since we can't trust the stream anymore
554
+ for (const { reject, timer } of pending.values()) {
555
+ clearTimeout(timer);
556
+ reject(
557
+ new Error(`Frame corruption: ${result.error} - ${result.message}`)
558
+ );
559
+ }
560
+ pending.clear();
561
+ return;
562
+ }
563
+
564
+ const msg = result.data;
565
+
566
+ // Handle event notifications (__kadi_event messages)
567
+ if (!msg.id && msg.method === '__kadi_event' && msg.params) {
568
+ // This is an event notification from the ability
569
+ const { eventName, data, timestamp } = msg.params;
570
+
571
+ // Log the event for debugging
572
+ if (logger && logger.enabled) {
573
+ logger.trace('event', `Received event notification: ${eventName}`);
574
+ }
575
+
576
+ // Call all registered event handlers
577
+ for (const handler of eventHandlers) {
578
+ try {
579
+ handler({
580
+ method: '__kadi_event',
581
+ params: { eventName, data, timestamp }
582
+ });
583
+ } catch (err) {
584
+ console.error(`[rpc] Error in event handler: ${err.message}`);
585
+ }
586
+ }
587
+
588
+ return; // Don't process further - events don't have responses
589
+ }
590
+
591
+ // Handle regular JSON-RPC responses
592
+ if (msg.id && pending.has(msg.id)) {
593
+ const { resolve, reject, timer } = pending.get(msg.id);
594
+ clearTimeout(timer);
595
+ pending.delete(msg.id);
596
+ if (msg.error) reject(new Error(msg.error.message || 'RPC error'));
597
+ else resolve(msg.result);
598
+ }
599
+ }
600
+
601
+ ability.stderr.on('data', (chunk) => {
602
+ // Optional: forward ability stderr to your logger
603
+ console.error(`[${cwd}]`, chunk.toString().trim());
604
+ if (logStream) logStream.write(chunk);
605
+ });
606
+
607
+ ability.on('close', (code) => {
608
+ // Fail all pending calls on exit
609
+ for (const { reject, timer } of pending.values()) {
610
+ clearTimeout(timer);
611
+ reject(new Error(`Ability process exited with code ${code}`));
612
+ }
613
+ pending.clear();
614
+ if (logStream) logStream.end();
615
+ });
616
+
617
+ function call(method, params) {
618
+ const id = nextId++;
619
+ const body = JSON.stringify({
620
+ jsonrpc: '2.0',
621
+ id,
622
+ method,
623
+ params: params ?? {}
624
+ });
625
+
626
+ // Compose LSP-style frame. We *could* omit Content-Type because UTF-8 JSON
627
+ // is the default, but including it keeps packet dumps self-describing.
628
+ const header =
629
+ `${FRAME_HEADERS.CONTENT_LENGTH}: ${Buffer.byteLength(body, 'utf8')}\r\n` +
630
+ `${FRAME_HEADERS.CONTENT_TYPE}: ${FRAME_VALUES.CONTENT_TYPE_VALUE}\r\n` +
631
+ `\r\n`;
632
+
633
+ return new Promise((resolve, reject) => {
634
+ const timer = setTimeout(() => {
635
+ pending.delete(id);
636
+ reject(new Error(`RPC timeout after ${timeoutMs}ms for ${method}`));
637
+ }, timeoutMs);
638
+
639
+ pending.set(id, { resolve, reject, timer });
640
+
641
+ try {
642
+ ability.stdin.write(header + body, 'utf8');
643
+ } catch (e) {
644
+ clearTimeout(timer);
645
+ pending.delete(id);
646
+ reject(e);
647
+ }
648
+ });
649
+ }
650
+
651
+ /**
652
+ * Register a handler for event notifications
653
+ * @param {Function} handler - Function to call when __kadi_event messages arrive
654
+ */
655
+ function onEvent(handler) {
656
+ if (typeof handler === 'function') {
657
+ eventHandlers.push(handler);
658
+ }
659
+ }
660
+
661
+ /**
662
+ * Remove an event handler
663
+ * @param {Function} handler - Handler to remove
664
+ */
665
+ function offEvent(handler) {
666
+ const index = eventHandlers.indexOf(handler);
667
+ if (index > -1) {
668
+ eventHandlers.splice(index, 1);
669
+ }
670
+ }
671
+
672
+ // Return the RPC interface with event support
673
+ return {
674
+ call,
675
+ onEvent, // Method to register event handlers
676
+ offEvent, // Method to unregister event handlers
677
+ // Expose the process for advanced use cases
678
+ _process: ability
679
+ };
680
+ }
681
+
682
+ /**
683
+ * Create a thin client that speaks request/response over the KADI Broker.
684
+ * Enhanced to support event notifications via kadi.event messages.
685
+ */
686
+ function createBrokerRPCClient({ brokerUrl, serviceName, timeoutMs, scope }) {
687
+ const idFactory = new IdFactory();
688
+
689
+ // Storage for event handlers
690
+ const eventHandlers = [];
691
+
692
+ function rpc(ws, messageBuilder) {
693
+ return new Promise((resolve) => {
694
+ const message = messageBuilder.build();
695
+ const onMessage = (raw) => {
696
+ try {
697
+ const msg = JSON.parse(raw.toString());
698
+ if (msg.id === message.id) {
699
+ ws.off('message', onMessage);
700
+ resolve(msg);
701
+ }
702
+ } catch {}
703
+ };
704
+ ws.on('message', onMessage);
705
+ ws.send(JSON.stringify(message));
706
+ });
707
+ }
708
+
709
+ async function connect() {
710
+ return new Promise((resolve, reject) => {
711
+ const ws = new WebSocket(brokerUrl);
712
+
713
+ // Handle connection errors
714
+ ws.once('error', (error) => {
715
+ reject(new Error(`WebSocket connection failed: ${error.message}`));
716
+ });
717
+
718
+ ws.once('open', async () => {
719
+ try {
720
+ // Remove error listener since connection succeeded
721
+ ws.removeAllListeners('error');
722
+
723
+ // Add error handler for runtime errors
724
+ ws.on('error', (error) => {
725
+ console.error(`Broker WebSocket error: ${error.message}`);
726
+ });
727
+
728
+ ws.on('message', (raw) => {
729
+ try {
730
+ const msg = JSON.parse(raw.toString());
731
+
732
+ // Check if this is an kadi.event notification
733
+ if (msg.method === 'kadi.event' && msg.params) {
734
+ const { eventName, eventData, timestamp, from } = msg.params;
735
+
736
+ // Log the event for debugging
737
+ if (logger && logger.enabled) {
738
+ logger.trace(
739
+ 'event',
740
+ `Received broker event: ${eventName} from ${from}`
741
+ );
742
+ }
743
+
744
+ // Call all registered event handlers
745
+ for (const handler of eventHandlers) {
746
+ try {
747
+ handler({
748
+ method: 'kadi.event',
749
+ params: { eventName, eventData, timestamp, from }
750
+ });
751
+ } catch (err) {
752
+ console.error(
753
+ `[broker-rpc] Error in event handler: ${err.message}`
754
+ );
755
+ }
756
+ }
757
+ }
758
+ } catch (err) {
759
+ // Ignore parsing errors for non-JSON messages
760
+ }
761
+ });
762
+
763
+ // hello/auth (ephemeral ed25519) using BrokerMessageBuilder
764
+ const helloMsg = Broker.hello({ role: 'agent', version: '0.1' }).id(
765
+ idFactory.next()
766
+ );
767
+ const hello = await rpc(ws, helloMsg);
768
+ const nonce = hello?.result?.nonce;
769
+
770
+ if (!nonce) {
771
+ throw new Error('No nonce received from broker hello');
772
+ }
773
+
774
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
775
+ const publicKeyBase64Der = toBase64Der(publicKey);
776
+
777
+ const authMsg = Broker.authenticate({
778
+ publicKeyBase64Der,
779
+ privateKey,
780
+ nonce,
781
+ wantNewId: true
782
+ }).id(idFactory.next());
783
+
784
+ const auth = await rpc(ws, authMsg);
785
+ if (auth.error) {
786
+ throw new Error('Broker auth failed: ' + auth.error.message);
787
+ }
788
+
789
+ // Register capabilities (no tools since we are a client)
790
+ const registerMsg = Broker.registerCapabilities({
791
+ displayName: `client:${serviceName}`,
792
+ scopes: ['global', scope],
793
+ tools: []
794
+ }).id(idFactory.next());
795
+
796
+ await rpc(ws, registerMsg);
797
+
798
+ // Heartbeat using ping notification
799
+ setInterval(() => {
800
+ if (ws.readyState === ws.OPEN) {
801
+ ws.send(Broker.ping().toString());
802
+ }
803
+ }, 25_000);
804
+
805
+ resolve(ws);
806
+ } catch (error) {
807
+ reject(error);
808
+ }
809
+ });
810
+ });
811
+ }
812
+
813
+ const ready = connect();
814
+
815
+ return {
816
+ async testConnection() {
817
+ // This will throw if connection fails during the handshake
818
+ const ws = await ready;
819
+ if (!ws || ws.readyState !== ws.OPEN) {
820
+ throw new Error('WebSocket connection failed');
821
+ }
822
+ },
823
+
824
+ async call(toolName, args) {
825
+ const ws = await ready;
826
+
827
+ // Use BrokerMessageBuilder for callAbility
828
+ const callMsg = Broker.callAbility({
829
+ toolName,
830
+ args
831
+ }).id(idFactory.next());
832
+
833
+ const ack = await rpc(ws, callMsg);
834
+ if (ack.error) {
835
+ throw new Error(ack.error.message || 'agent.callAbility failed');
836
+ }
837
+
838
+ const expectedRequestId = ack?.result?.requestId;
839
+ return await new Promise((resolve, reject) => {
840
+ const timer = setTimeout(() => {
841
+ ws.off('message', onMessage);
842
+ reject(new Error('broker call timeout'));
843
+ }, timeoutMs || 10000);
844
+
845
+ const onMessage = (raw) => {
846
+ try {
847
+ const msg = JSON.parse(raw.toString());
848
+ if (msg.method === 'ability.result') {
849
+ const { requestId, result, error } = msg.params || {};
850
+ if (!expectedRequestId || requestId === expectedRequestId) {
851
+ clearTimeout(timer);
852
+ ws.off('message', onMessage);
853
+ if (error) reject(new Error(error.message || 'ability error'));
854
+ else resolve(result);
855
+ }
856
+ }
857
+ } catch {}
858
+ };
859
+ ws.on('message', onMessage);
860
+ });
861
+ },
862
+
863
+ async getAvailableMethods(scopes = ['global']) {
864
+ const ws = await ready;
865
+ const msg = Broker.listTools({ scopes }).id(idFactory.next());
866
+ const response = await rpc(ws, msg);
867
+ if (response.error) {
868
+ throw new Error(response.error.message || 'listTools failed');
869
+ }
870
+ return response.result;
871
+ },
872
+
873
+ /**
874
+ * Register a handler for event notifications
875
+ * @param {Function} handler - Function to call when kadi.event messages
876
+ * arrive
877
+ */
878
+ onEvent(handler) {
879
+ if (typeof handler === 'function') {
880
+ eventHandlers.push(handler);
881
+ }
882
+ },
883
+
884
+ /**
885
+ * Remove an event handler
886
+ * @param {Function} handler - Handler to remove
887
+ */
888
+ offEvent(handler) {
889
+ const index = eventHandlers.indexOf(handler);
890
+ if (index > -1) {
891
+ eventHandlers.splice(index, 1);
892
+ }
893
+ }
894
+ };
895
+ }
896
+
897
+ /**
898
+ * Set up event listener for stdio transport
899
+ * Listens for __kadi_event notifications and emits them on the EventEmitter
900
+ *
901
+ * @param {Object} rpc - The RPC object returned from spawnJSONRPCProcess
902
+ * @param {EventEmitter} eventEmitter - The EventEmitter to emit events on
903
+ */
904
+ function setupStdioEventListener(rpc, eventEmitter) {
905
+ if (!rpc.onEvent) {
906
+ logger.warn('event', 'RPC object does not support event notifications');
907
+ return;
908
+ }
909
+
910
+ // Register handler for __kadi_event messages
911
+ rpc.onEvent((message) => {
912
+ if (message.method === '__kadi_event' && message.params) {
913
+ const { eventName, data, timestamp } = message.params;
914
+
915
+ logger.trace('event', `Received stdio event: ${eventName}`);
916
+ logger.trace('event-data', { eventName, data, timestamp });
917
+
918
+ // Emit the event on the EventEmitter
919
+ // This allows agents to do: ability.events.on('eventName', handler)
920
+ eventEmitter.emit(eventName, data);
921
+
922
+ // Also emit a generic 'event' event for catch-all handlers
923
+ eventEmitter.emit('event', { eventName, data, timestamp });
924
+ }
925
+ });
926
+
927
+ logger.trace('event', 'Stdio event listener configured');
928
+ }
929
+
930
+ /**
931
+ * Set up event listener for broker transport
932
+ * Listens for kadi.event messages and emits them on the EventEmitter
933
+ *
934
+ * @param {Object} rpc - The RPC client returned from createBrokerRPCClient
935
+ * @param {EventEmitter} eventEmitter - The EventEmitter to emit events on
936
+ */
937
+ function setupBrokerEventListener(rpc, eventEmitter) {
938
+ if (!rpc.onEvent) {
939
+ logger.warn('event', 'RPC client does not support event notifications');
940
+ return;
941
+ }
942
+
943
+ // Register handler for kadi.event messages
944
+ rpc.onEvent((message) => {
945
+ if (message.method === 'kadi.event' && message.params) {
946
+ const { eventName, eventData, timestamp, from } = message.params;
947
+
948
+ logger.trace('event', `Received broker event: ${eventName} from ${from}`);
949
+ logger.trace('event-data', { eventName, eventData, timestamp, from });
950
+
951
+ // Emit the event on the EventEmitter
952
+ // Use eventData (broker's field name) as the data payload
953
+ eventEmitter.emit(eventName, eventData);
954
+
955
+ // Also emit a generic 'event' event for catch-all handlers
956
+ eventEmitter.emit('event', {
957
+ eventName,
958
+ data: eventData,
959
+ timestamp,
960
+ source: from
961
+ });
962
+ }
963
+ });
964
+
965
+ logger.trace('event', 'Broker event listener configured');
966
+ }
967
+
968
+ /**
969
+ * Set up event listener for native transport
970
+ * For native abilities that are KadiAbility instances
971
+ *
972
+ * @param {any} rawModule - The imported module (might be a KadiAbility instance)
973
+ * @param {EventEmitter} eventEmitter - The EventEmitter to emit events on
974
+ */
975
+ function setupNativeEventListener(rawModule, eventEmitter) {
976
+ // Check if the module is an EventEmitter (like KadiAbility)
977
+ if (rawModule && typeof rawModule.on === 'function') {
978
+ logger.trace('event', 'Native module appears to be an EventEmitter');
979
+
980
+ // Remove any existing ability:event listeners first
981
+ rawModule.removeAllListeners('ability:event');
982
+
983
+ // Listen for ability:event which KadiAbility emits for native protocol
984
+ rawModule.on('ability:event', ({ eventName, data }) => {
985
+ logger.trace('event', `Received native event: ${eventName}`);
986
+ logger.trace('event-data', { eventName, data });
987
+
988
+ // Emit the event on the proxy's EventEmitter
989
+ eventEmitter.emit(eventName, data);
990
+
991
+ // Also emit a generic 'event' event
992
+ eventEmitter.emit('event', { eventName, data, timestamp: Date.now() });
993
+ });
994
+
995
+ logger.trace('event', 'Native event listener configured');
996
+ } else {
997
+ logger.trace(
998
+ 'event',
999
+ 'Native module is not an EventEmitter, skipping event setup'
1000
+ );
1001
+ }
1002
+ }
1003
+
1004
+ /**
1005
+ * Enhanced buildAbilityProxy with event support
1006
+ * Replace the existing buildAbilityProxy function with this version
1007
+ */
1008
+ function buildAbilityProxy({ call, functions, eventEmitter }) {
1009
+ // Create EventEmitter if not provided
1010
+ const events = eventEmitter || new EventEmitter();
1011
+
1012
+ // Set max listeners to avoid warnings when many handlers are attached
1013
+ events.setMaxListeners(100);
1014
+
1015
+ const base = {
1016
+ call: async (method, params) => {
1017
+ try {
1018
+ return await call(method, params);
1019
+ } catch (error) {
1020
+ // Enhance error messages for better user experience
1021
+ if (functions && !functions[method]) {
1022
+ const availableMethods = Object.keys(functions);
1023
+ throw new Error(
1024
+ `Method '${method}' is not exposed by this ability.\n` +
1025
+ `Available methods: ${availableMethods.length > 0 ? availableMethods.join(', ') : 'none'}\n` +
1026
+ `Use ability.__list() to see all available methods.`
1027
+ );
1028
+ }
1029
+ throw error;
1030
+ }
1031
+ },
1032
+ __list: () => (functions ? Object.keys(functions) : []),
1033
+ events // Expose the EventEmitter for event subscriptions
1034
+ };
1035
+
1036
+ if (!functions) {
1037
+ // No discovery -> return generic proxy that routes any property call to RPC .call(name)
1038
+ return new Proxy(base, {
1039
+ get(target, prop) {
1040
+ if (prop in target) return target[prop];
1041
+ if (prop === 'then') return undefined; // Avoid being treated as a Promise/thenable
1042
+ if (typeof prop !== 'string') return undefined;
1043
+ return async (params) => {
1044
+ try {
1045
+ return await call(prop, params);
1046
+ } catch (error) {
1047
+ // For abilities without discovery, provide a generic helpful message
1048
+ throw new Error(
1049
+ `Failed to call method '${prop}' on ability.\n` +
1050
+ `Original error: ${error.message}\n` +
1051
+ `Tip: Use ability.__list() to see available methods or check if the method name is correct.`
1052
+ );
1053
+ }
1054
+ };
1055
+ }
1056
+ });
1057
+ }
1058
+
1059
+ // Discovery available → define concrete methods plus dynamic fallback
1060
+ for (const fnName of Object.keys(functions)) {
1061
+ base[fnName] = async (params) => {
1062
+ try {
1063
+ return await call(fnName, params);
1064
+ } catch (error) {
1065
+ throw new Error(`Error calling method '${fnName}': ${error.message}`);
1066
+ }
1067
+ };
1068
+ }
1069
+
1070
+ return new Proxy(base, {
1071
+ get(target, prop) {
1072
+ if (prop in target) return target[prop];
1073
+ if (prop === 'then') return undefined; // Avoid thenable detection
1074
+ if (typeof prop !== 'string') return undefined;
1075
+ // Unknown method name → provide helpful error
1076
+ return async (params) => {
1077
+ const availableMethods = Object.keys(functions);
1078
+ throw new Error(
1079
+ `Method '${prop}' is not exposed by this ability.\n` +
1080
+ `Available methods: ${availableMethods.length > 0 ? availableMethods.join(', ') : 'none'}\n` +
1081
+ `Use ability.__list() to see all available methods.`
1082
+ );
1083
+ };
1084
+ }
1085
+ });
1086
+ }