@kadi.build/core 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.ts ADDED
@@ -0,0 +1,2385 @@
1
+ /**
2
+ * KadiClient for kadi-core v0.1.0
3
+ *
4
+ * The main entry point for building KADI agents.
5
+ * This client handles:
6
+ * - Broker connections (WebSocket with Ed25519 authentication)
7
+ * - Tool registration (local tools that can be invoked remotely)
8
+ * - Ability loading (native, stdio, broker transports)
9
+ * - Remote invocation (call tools on other agents)
10
+ * - Serve modes (stdio server, broker server)
11
+ *
12
+ * Design principles:
13
+ * - Named brokers with optional defaultBroker
14
+ * - Explicit over implicit (no proxy magic)
15
+ * - Readable, traceable code flow
16
+ */
17
+
18
+ import { WebSocket } from 'ws';
19
+ import * as crypto from 'crypto';
20
+ import {
21
+ convertToEncryptionKey,
22
+ convertToEncryptionKeyPair,
23
+ type EncryptionKeyPair,
24
+ } from './crypto.js';
25
+ // @ts-expect-error - tweetnacl-sealedbox-js doesn't have type definitions
26
+ import sealedbox from 'tweetnacl-sealedbox-js';
27
+ import type {
28
+ ClientConfig,
29
+ ResolvedConfig,
30
+ ClientIdentity,
31
+ BrokerState,
32
+ RegisteredTool,
33
+ ToolDefinition,
34
+ ZodToolDefinition,
35
+ ToolHandler,
36
+ LoadedAbility,
37
+ ToolExecutionBridge,
38
+ RegisterToolOptions,
39
+ LoadNativeOptions,
40
+ LoadStdioOptions,
41
+ LoadBrokerOptions,
42
+ InvokeRemoteOptions,
43
+ JsonRpcRequest,
44
+ JsonRpcResponse,
45
+ JsonRpcNotification,
46
+ RequestContext,
47
+ // Broker events (pub/sub)
48
+ BrokerEvent,
49
+ BrokerEventHandler,
50
+ PublishOptions,
51
+ SubscribeOptions,
52
+ EmitOptions,
53
+ } from './types.js';
54
+ import { KadiError } from './errors.js';
55
+ import { zodToJsonSchema, isZodSchema } from './zod.js';
56
+ import { resolveAbilityEntry, resolveAbilityScript } from './lockfile.js';
57
+ import { loadNativeTransport } from './transports/native.js';
58
+ import { loadStdioTransport } from './transports/stdio.js';
59
+ import { loadBrokerTransport } from './transports/broker.js';
60
+
61
+ // ═══════════════════════════════════════════════════════════════
62
+ // CONSTANTS
63
+ // ═══════════════════════════════════════════════════════════════
64
+
65
+ const DEFAULT_HEARTBEAT_INTERVAL = 25000; // 25 seconds
66
+ const DEFAULT_REQUEST_TIMEOUT = 600000; // 10 minutes
67
+ const DEFAULT_AUTO_RECONNECT = true;
68
+ const DEFAULT_MAX_RECONNECT_DELAY = 30000; // 30 seconds cap
69
+ const RECONNECT_BASE_DELAY = 1000; // 1 second initial delay
70
+
71
+ // ═══════════════════════════════════════════════════════════════
72
+ // MCP CONTENT DETECTION
73
+ // ═══════════════════════════════════════════════════════════════
74
+
75
+ /**
76
+ * Check if a value is a valid MCP ContentBlock.
77
+ *
78
+ * MCP supports these content types:
79
+ * - TextContent: { type: 'text', text: string }
80
+ * - ImageContent: { type: 'image', data: string, mimeType: string }
81
+ * - AudioContent: { type: 'audio', data: string, mimeType: string }
82
+ * - EmbeddedResource: { type: 'resource', resource: object }
83
+ * - ResourceLink: { type: 'resource_link', uri: string }
84
+ */
85
+ function isValidMcpContentBlock(item: unknown): boolean {
86
+ if (typeof item !== 'object' || item === null) return false;
87
+ const block = item as Record<string, unknown>;
88
+
89
+ switch (block.type) {
90
+ case 'text':
91
+ return typeof block.text === 'string';
92
+ case 'image':
93
+ case 'audio':
94
+ return typeof block.data === 'string' && typeof block.mimeType === 'string';
95
+ case 'resource':
96
+ return typeof block.resource === 'object' && block.resource !== null;
97
+ case 'resource_link':
98
+ return typeof block.uri === 'string';
99
+ default:
100
+ return false;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Check if a result is already in MCP CallToolResult format.
106
+ *
107
+ * This allows tool handlers to return MCP-shaped content (like images)
108
+ * directly, without being double-wrapped.
109
+ *
110
+ * A valid CallToolResult has at least one of:
111
+ * - content: array of ContentBlock (with at least one valid block)
112
+ * - structuredContent: object (JSON data)
113
+ *
114
+ * Optional fields (not used for detection):
115
+ * - isError?: boolean
116
+ *
117
+ * @example
118
+ * // MCP-shaped with content (passes through unchanged):
119
+ * { content: [{ type: 'image', data: 'base64...', mimeType: 'image/png' }] }
120
+ *
121
+ * // MCP-shaped with structuredContent (passes through unchanged):
122
+ * { structuredContent: { models: ['gpt-4', 'claude-3'] } }
123
+ *
124
+ * // NOT MCP-shaped (gets wrapped as text):
125
+ * { models: ['gpt-4', 'claude-3'] }
126
+ */
127
+ function isMcpCallToolResult(result: unknown): boolean {
128
+ if (typeof result !== 'object' || result === null) return false;
129
+
130
+ const obj = result as Record<string, unknown>;
131
+
132
+ // Check for valid content array (non-empty, all valid blocks)
133
+ const hasValidContent =
134
+ Array.isArray(obj.content) &&
135
+ obj.content.length > 0 &&
136
+ obj.content.every(isValidMcpContentBlock);
137
+
138
+ // Check for structuredContent (any non-null object)
139
+ const hasStructuredContent =
140
+ typeof obj.structuredContent === 'object' && obj.structuredContent !== null;
141
+
142
+ // Must have either content OR structuredContent (or both)
143
+ return hasValidContent || hasStructuredContent;
144
+ }
145
+
146
+ // ═══════════════════════════════════════════════════════════════
147
+ // KADCLIENT CLASS
148
+ // ═══════════════════════════════════════════════════════════════
149
+
150
+ /**
151
+ * The main client for building KADI agents.
152
+ *
153
+ * @example
154
+ * ```typescript
155
+ * // Create a client with named brokers
156
+ * const client = new KadiClient({
157
+ * name: 'my-agent',
158
+ * brokers: {
159
+ * production: 'ws://broker-prod:8080',
160
+ * internal: 'ws://broker-internal:8080',
161
+ * },
162
+ * defaultBroker: 'production',
163
+ * });
164
+ *
165
+ * // Register a tool
166
+ * client.registerTool({
167
+ * name: 'add',
168
+ * description: 'Add two numbers',
169
+ * input: z.object({ a: z.number(), b: z.number() }),
170
+ * }, async ({ a, b }) => ({ result: a + b }));
171
+ *
172
+ * // Connect to brokers
173
+ * await client.connect();
174
+ *
175
+ * // Load and use abilities
176
+ * const calc = await client.loadNative('calculator');
177
+ * const result = await calc.invoke('multiply', { x: 5, y: 3 });
178
+ * ```
179
+ */
180
+ export class KadiClient {
181
+ /** Resolved configuration with defaults applied */
182
+ private readonly config: ResolvedConfig;
183
+
184
+ /** Registered tools (local tools this agent provides) */
185
+ private readonly tools: Map<string, RegisteredTool> = new Map();
186
+
187
+ /** Broker connections by name */
188
+ private readonly brokers: Map<string, BrokerState> = new Map();
189
+
190
+ /** Counter for generating unique request IDs */
191
+ private nextRequestId = 0;
192
+
193
+ /** Event handler callback (set by native transport) */
194
+ private eventHandler: ((event: string, data: unknown) => void) | null = null;
195
+
196
+ /** Whether we're serving via stdio (set by serve('stdio')) */
197
+ private isServingStdio = false;
198
+
199
+ // ─────────────────────────────────────────────────────────────
200
+ // IDENTITY (single keypair shared across all broker connections)
201
+ // ─────────────────────────────────────────────────────────────
202
+
203
+ /** Ed25519 private key (DER format) - used for signing */
204
+ private readonly _privateKey: Buffer;
205
+
206
+ /** Base64-encoded Ed25519 public key (SPKI DER format) */
207
+ private readonly _publicKeyBase64: string;
208
+
209
+ /** Deterministic agent ID: SHA256(publicKey).hex().substring(0, 16) */
210
+ private readonly _agentId: string;
211
+
212
+ // ─────────────────────────────────────────────────────────────
213
+ // CONSTRUCTOR
214
+ // ─────────────────────────────────────────────────────────────
215
+
216
+ constructor(config: ClientConfig) {
217
+ // Validate required fields
218
+ if (!config.name || typeof config.name !== 'string') {
219
+ throw new KadiError('Client name is required', 'INVALID_CONFIG', {
220
+ hint: 'Provide a name for your agent: new KadiClient({ name: "my-agent" })',
221
+ });
222
+ }
223
+
224
+ // Use provided identity or generate ephemeral Ed25519 keypair
225
+ if (config.identity) {
226
+ this._privateKey = config.identity.privateKey;
227
+ this._publicKeyBase64 = config.identity.publicKey;
228
+ } else {
229
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
230
+ this._privateKey = privateKey.export({ type: 'pkcs8', format: 'der' });
231
+ this._publicKeyBase64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
232
+ }
233
+
234
+ // Derive agentId using same algorithm as broker: SHA256(publicKey).hex().substring(0, 16)
235
+ this._agentId = crypto.createHash('sha256').update(this._publicKeyBase64).digest('hex').substring(0, 16);
236
+
237
+ // Resolve configuration with defaults
238
+ // Auto-select first broker as default if not specified (matches Python behavior)
239
+ const brokers = config.brokers ?? {};
240
+ const firstBrokerName = Object.keys(brokers)[0];
241
+
242
+ this.config = {
243
+ name: config.name,
244
+ version: config.version ?? '1.0.0',
245
+ description: config.description ?? '',
246
+ brokers,
247
+ defaultBroker: config.defaultBroker ?? firstBrokerName,
248
+ networks: config.networks ?? ['global'],
249
+ heartbeatInterval: config.heartbeatInterval ?? DEFAULT_HEARTBEAT_INTERVAL,
250
+ requestTimeout: config.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT,
251
+ autoReconnect: config.autoReconnect ?? DEFAULT_AUTO_RECONNECT,
252
+ maxReconnectDelay: config.maxReconnectDelay ?? DEFAULT_MAX_RECONNECT_DELAY,
253
+ };
254
+
255
+ // Validate defaultBroker if specified
256
+ if (this.config.defaultBroker && !this.config.brokers[this.config.defaultBroker]) {
257
+ throw new KadiError(
258
+ `Default broker "${this.config.defaultBroker}" not found in brokers config`,
259
+ 'INVALID_CONFIG',
260
+ {
261
+ defaultBroker: this.config.defaultBroker,
262
+ available: Object.keys(this.config.brokers),
263
+ hint: 'defaultBroker must be a key in the brokers map',
264
+ }
265
+ );
266
+ }
267
+ }
268
+
269
+ // ─────────────────────────────────────────────────────────────
270
+ // IDENTITY GETTERS
271
+ // ─────────────────────────────────────────────────────────────
272
+
273
+ /**
274
+ * Get the client's cryptographic identity.
275
+ *
276
+ * Available immediately after construction (before connect).
277
+ * The same identity is used for all broker connections.
278
+ *
279
+ * @example
280
+ * ```typescript
281
+ * const client = new KadiClient({ name: 'my-agent' });
282
+ * console.log(client.identity);
283
+ * // { publicKey: 'MIIBIjAN...', agentId: 'a1b2c3d4e5f67890' }
284
+ * ```
285
+ */
286
+ get identity(): ClientIdentity {
287
+ return {
288
+ publicKey: this._publicKeyBase64,
289
+ agentId: this._agentId,
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Get the client's public key (base64-encoded SPKI DER format).
295
+ *
296
+ * This is the key used for Ed25519 authentication with brokers.
297
+ */
298
+ get publicKey(): string {
299
+ return this._publicKeyBase64;
300
+ }
301
+
302
+ /**
303
+ * Get the client's agent ID.
304
+ *
305
+ * Derived deterministically from publicKey: SHA256(publicKey).hex().substring(0, 16)
306
+ * This is the same ID that brokers will assign during authentication.
307
+ */
308
+ get agentId(): string {
309
+ return this._agentId;
310
+ }
311
+
312
+ // ─────────────────────────────────────────────────────────────
313
+ // ENCRYPTION KEY GETTERS
314
+ // ─────────────────────────────────────────────────────────────
315
+
316
+ /**
317
+ * Get the client's X25519 encryption public key.
318
+ *
319
+ * This key is derived from the client's Ed25519 signing key and can be
320
+ * shared with others so they can encrypt messages that only this client
321
+ * can decrypt.
322
+ *
323
+ * Use this when you want others to send encrypted data to your agent.
324
+ *
325
+ * @example
326
+ * ```typescript
327
+ * // Agent shares its encryption public key
328
+ * await client.publish('secrets.request', {
329
+ * agentId: client.agentId,
330
+ * publicKey: client.publicKey, // Ed25519 for identity
331
+ * // The sender will use convertToEncryptionKey(publicKey) to encrypt
332
+ * });
333
+ * ```
334
+ */
335
+ get encryptionPublicKey(): Uint8Array {
336
+ return convertToEncryptionKey(this._publicKeyBase64);
337
+ }
338
+
339
+ /**
340
+ * Get the client's X25519 encryption key pair.
341
+ *
342
+ * This key pair is derived from the client's Ed25519 signing keys and can
343
+ * be used to decrypt messages that were encrypted with the client's
344
+ * encryption public key.
345
+ *
346
+ * Use this when you need to decrypt sealed box messages sent to your agent.
347
+ *
348
+ * @example
349
+ * ```typescript
350
+ * import nacl from 'tweetnacl';
351
+ * nacl.sealedbox = require('tweetnacl-sealedbox-js');
352
+ *
353
+ * // Decrypt a sealed box message
354
+ * const keyPair = client.encryptionKeyPair;
355
+ * const decrypted = nacl.sealedbox.open(
356
+ * encrypted,
357
+ * keyPair.publicKey,
358
+ * keyPair.secretKey
359
+ * );
360
+ * ```
361
+ */
362
+ get encryptionKeyPair(): EncryptionKeyPair {
363
+ return convertToEncryptionKeyPair(this._privateKey, this._publicKeyBase64);
364
+ }
365
+
366
+ /**
367
+ * Get the agent name (from config).
368
+ */
369
+ get name(): string {
370
+ return this.config.name;
371
+ }
372
+
373
+ /**
374
+ * Get the agent version (from config).
375
+ */
376
+ get version(): string {
377
+ return this.config.version;
378
+ }
379
+
380
+ // ─────────────────────────────────────────────────────────────
381
+ // CONNECTION MANAGEMENT
382
+ // ─────────────────────────────────────────────────────────────
383
+
384
+ /**
385
+ * Connect to all configured brokers.
386
+ * Call this after registering tools.
387
+ *
388
+ * @example
389
+ * ```typescript
390
+ * await client.connect(); // Connects to all brokers
391
+ * await client.connect('production'); // Connect to specific broker
392
+ * ```
393
+ */
394
+ async connect(brokerName?: string): Promise<void> {
395
+ // If specific broker requested, connect to just that one
396
+ if (brokerName) {
397
+ await this.connectToBroker(brokerName);
398
+ return;
399
+ }
400
+
401
+ // Connect to all configured brokers
402
+ const brokerNames = Object.keys(this.config.brokers);
403
+ if (brokerNames.length === 0) {
404
+ // No brokers configured - that's okay for local-only usage
405
+ return;
406
+ }
407
+
408
+ // Connect to all brokers in parallel, continuing even if some fail
409
+ const results = await Promise.allSettled(
410
+ brokerNames.map((name) => this.connectToBroker(name))
411
+ );
412
+
413
+ // Log failures but don't throw unless ALL brokers failed
414
+ const failures: string[] = [];
415
+ for (const [i, result] of results.entries()) {
416
+ if (result.status === 'rejected') {
417
+ const failedBroker = brokerNames[i];
418
+ if (failedBroker === undefined) continue;
419
+ const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
420
+ console.error(`[KADI] Failed to connect to broker "${failedBroker}": ${reason}`);
421
+ failures.push(failedBroker);
422
+ }
423
+ }
424
+
425
+ // Only throw if ALL brokers failed
426
+ if (failures.length === brokerNames.length) {
427
+ throw new KadiError(
428
+ `Failed to connect to any broker`,
429
+ 'CONNECTION_FAILED',
430
+ { failedBrokers: failures }
431
+ );
432
+ }
433
+
434
+ // Log partial success
435
+ if (failures.length > 0) {
436
+ const succeeded = brokerNames.length - failures.length;
437
+ console.error(`[KADI] Connected to ${succeeded}/${brokerNames.length} brokers`);
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Connect to a specific broker by name.
443
+ *
444
+ * Protocol:
445
+ * 1. Open WebSocket connection
446
+ * 2. Send kadi.session.hello
447
+ * 3. Receive nonce from broker
448
+ * 4. Send kadi.session.authenticate with signed nonce (using client's identity)
449
+ * 5. Receive agentId from broker (should match client.agentId)
450
+ * 6. Register tools with broker
451
+ * 7. Start heartbeat
452
+ *
453
+ * Note: The keypair is generated once at client construction and shared
454
+ * across all broker connections, ensuring consistent identity.
455
+ */
456
+ private async connectToBroker(brokerName: string): Promise<void> {
457
+ const url = this.config.brokers[brokerName];
458
+ if (!url) {
459
+ throw new KadiError(`Broker "${brokerName}" not found in configuration`, 'UNKNOWN_BROKER', {
460
+ broker: brokerName,
461
+ available: Object.keys(this.config.brokers),
462
+ hint: 'Check your brokers configuration',
463
+ });
464
+ }
465
+
466
+ // Check if already connected
467
+ if (this.brokers.has(brokerName)) {
468
+ const existing = this.brokers.get(brokerName)!;
469
+ if (existing.status === 'connected' || existing.status === 'connecting') {
470
+ return;
471
+ }
472
+ }
473
+
474
+ // Create broker state (identity is at client level, not per-broker)
475
+ const state: BrokerState = {
476
+ name: brokerName,
477
+ url,
478
+ ws: null,
479
+ heartbeatTimer: null,
480
+ pendingRequests: new Map(),
481
+ pendingInvocations: new Map(),
482
+ status: 'connecting',
483
+ // Reconnection state
484
+ intentionalDisconnect: false,
485
+ reconnectAttempts: 0,
486
+ reconnectTimer: null,
487
+ // Event subscriptions (pub/sub)
488
+ eventHandlers: new Map(),
489
+ subscribedPatterns: new Set(),
490
+ };
491
+ this.brokers.set(brokerName, state);
492
+
493
+ // Open WebSocket connection
494
+ await this.openWebSocket(state);
495
+
496
+ // Perform handshake
497
+ await this.performHandshake(state);
498
+
499
+ // Register with broker (transitions to "ready" state)
500
+ await this.registerWithBroker(state);
501
+
502
+ // Start heartbeat
503
+ state.heartbeatTimer = setInterval(() => {
504
+ this.sendHeartbeat(state);
505
+ }, this.config.heartbeatInterval);
506
+
507
+ state.status = 'connected';
508
+ }
509
+
510
+ // ─────────────────────────────────────────────────────────────
511
+ // MESSAGE BUILDERS
512
+ // ─────────────────────────────────────────────────────────────
513
+
514
+ private buildHelloMessage(): JsonRpcRequest {
515
+ return {
516
+ jsonrpc: '2.0',
517
+ id: this.nextRequestId++,
518
+ method: 'kadi.session.hello',
519
+ params: { role: 'agent' },
520
+ };
521
+ }
522
+
523
+ private buildAuthMessage(nonce: string): JsonRpcRequest {
524
+ // Sign the nonce using the client's private key
525
+ const privateKey = crypto.createPrivateKey({
526
+ key: this._privateKey,
527
+ format: 'der',
528
+ type: 'pkcs8',
529
+ });
530
+ const signature = crypto.sign(null, Buffer.from(nonce, 'utf8'), privateKey);
531
+
532
+ return {
533
+ jsonrpc: '2.0',
534
+ id: this.nextRequestId++,
535
+ method: 'kadi.session.authenticate',
536
+ params: {
537
+ publicKey: this._publicKeyBase64,
538
+ signature: signature.toString('base64'),
539
+ nonce,
540
+ },
541
+ };
542
+ }
543
+
544
+ private buildRegisterMessage(tools: ToolDefinition[], networks: string[]): JsonRpcRequest {
545
+ return {
546
+ jsonrpc: '2.0',
547
+ id: this.nextRequestId++,
548
+ method: 'kadi.agent.register',
549
+ params: {
550
+ tools,
551
+ networks,
552
+ displayName: this.config.name,
553
+ },
554
+ };
555
+ }
556
+
557
+ private buildHeartbeatMessage(): JsonRpcRequest {
558
+ return {
559
+ jsonrpc: '2.0',
560
+ id: this.nextRequestId++,
561
+ method: 'kadi.session.heartbeat',
562
+ params: { timestamp: Date.now() },
563
+ };
564
+ }
565
+
566
+ // ─────────────────────────────────────────────────────────────
567
+ // CONNECTION HELPERS
568
+ // ─────────────────────────────────────────────────────────────
569
+
570
+ /**
571
+ * Open WebSocket connection to broker.
572
+ */
573
+ private openWebSocket(state: BrokerState): Promise<void> {
574
+ return new Promise((resolve, reject) => {
575
+ const ws = new WebSocket(state.url);
576
+ state.ws = ws;
577
+
578
+ const timeout = setTimeout(() => {
579
+ ws.close();
580
+ reject(
581
+ new KadiError(`Timeout connecting to broker "${state.name}"`, 'BROKER_TIMEOUT', {
582
+ broker: state.name,
583
+ url: state.url,
584
+ })
585
+ );
586
+ }, this.config.requestTimeout);
587
+
588
+ ws.on('open', () => {
589
+ clearTimeout(timeout);
590
+ resolve();
591
+ });
592
+
593
+ ws.on('error', (error) => {
594
+ clearTimeout(timeout);
595
+ state.status = 'disconnected';
596
+ reject(
597
+ new KadiError(`Failed to connect to broker "${state.name}"`, 'CONNECTION_FAILED', {
598
+ broker: state.name,
599
+ url: state.url,
600
+ reason: error.message,
601
+ })
602
+ );
603
+ });
604
+
605
+ ws.on('close', () => {
606
+ this.handleWebSocketClose(state);
607
+ });
608
+
609
+ ws.on('message', (data) => this.handleBrokerMessage(state, data));
610
+ });
611
+ }
612
+
613
+ /**
614
+ * Perform handshake: hello → authenticate.
615
+ *
616
+ * Protocol:
617
+ * 1. Client sends kadi.session.hello
618
+ * 2. Broker responds with { nonce: "..." }
619
+ * 3. Client signs nonce and sends kadi.session.authenticate
620
+ * 4. Broker responds with { agentId: "..." }
621
+ */
622
+ private async performHandshake(state: BrokerState): Promise<void> {
623
+ // Step 1: Send hello, get nonce for challenge-response auth
624
+ // The broker sends a random nonce that we must sign to prove key ownership
625
+ const helloResult = await this.sendRequest<{ nonce: string }>(
626
+ state,
627
+ this.buildHelloMessage()
628
+ );
629
+
630
+ // Validate the response shape - sendRequest<T> is type documentation only,
631
+ // not runtime enforcement. We must validate critical fields ourselves.
632
+ if (!helloResult.nonce) {
633
+ throw new KadiError('Hello response missing nonce', 'AUTHENTICATION_FAILED', {
634
+ broker: state.name,
635
+ hint: 'Broker may be misconfigured or running incompatible version',
636
+ });
637
+ }
638
+
639
+ // Step 2: Sign the nonce and authenticate
640
+ // This proves we own the private key without revealing it
641
+ const authResult = await this.sendRequest<{ agentId: string }>(
642
+ state,
643
+ this.buildAuthMessage(helloResult.nonce)
644
+ );
645
+
646
+ if (!authResult.agentId) {
647
+ throw new KadiError('Auth response missing agentId', 'AUTHENTICATION_FAILED', {
648
+ broker: state.name,
649
+ });
650
+ }
651
+
652
+ // Verify broker assigned the expected agentId (sanity check)
653
+ if (authResult.agentId !== this._agentId) {
654
+ console.warn(
655
+ `[KADI] Broker "${state.name}" returned different agentId: ` +
656
+ `expected ${this._agentId}, got ${authResult.agentId}. ` +
657
+ `This may indicate the broker uses a different ID derivation algorithm.`
658
+ );
659
+ }
660
+ }
661
+
662
+ /**
663
+ * Register with broker after authentication.
664
+ *
665
+ * This transitions the session from "authenticated" to "ready" state.
666
+ * Must be called even if no tools are registered - the broker requires
667
+ * the agent to be in "ready" state before it can invoke remote tools.
668
+ */
669
+ private async registerWithBroker(state: BrokerState): Promise<void> {
670
+ // Get tools targeted for this specific broker
671
+ const tools = this.getToolDefinitions(state.name);
672
+
673
+ // Note: If registration fails, sendRequest will reject with the error.
674
+ // We don't need to check response.error here - errors are already
675
+ // handled via Promise rejection in handleBrokerResponse.
676
+ await this.sendRequest<{ status: string }>(
677
+ state,
678
+ this.buildRegisterMessage(tools, this.config.networks)
679
+ );
680
+ }
681
+
682
+ /**
683
+ * Send a JSON-RPC request and wait for the response.
684
+ * Returns the result directly (not the JSON-RPC envelope).
685
+ * Note: Type parameter T is not validated at runtime.
686
+ */
687
+ private sendRequest<T = unknown>(state: BrokerState, request: JsonRpcRequest): Promise<T> {
688
+ // Fail fast if WebSocket is not connected
689
+ // Without this check, the request would sit pending until timeout (30s),
690
+ // giving a misleading BROKER_TIMEOUT error instead of the real problem.
691
+ const ws = state.ws;
692
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
693
+ return Promise.reject(
694
+ new KadiError('Cannot send request: WebSocket not connected', 'BROKER_NOT_CONNECTED', {
695
+ broker: state.name,
696
+ method: request.method,
697
+ hint: 'Call client.connect() first',
698
+ })
699
+ );
700
+ }
701
+
702
+ return new Promise((resolve, reject) => {
703
+ const timeout = setTimeout(() => {
704
+ state.pendingRequests.delete(request.id);
705
+ reject(
706
+ new KadiError(`Request timeout: ${request.method}`, 'BROKER_TIMEOUT', {
707
+ broker: state.name,
708
+ method: request.method,
709
+ })
710
+ );
711
+ }, this.config.requestTimeout);
712
+
713
+ // Store the pending request. When handleBrokerResponse receives a response,
714
+ // it will clear the timeout, delete from map, and call resolve/reject.
715
+ state.pendingRequests.set(request.id, {
716
+ resolve: (result: unknown) => resolve(result as T),
717
+ reject,
718
+ timeout,
719
+ method: request.method,
720
+ sentAt: new Date(),
721
+ });
722
+
723
+ ws.send(JSON.stringify(request));
724
+ });
725
+ }
726
+
727
+ // ─────────────────────────────────────────────────────────────
728
+ // MESSAGE HANDLING
729
+ // ─────────────────────────────────────────────────────────────
730
+
731
+ /**
732
+ * Handle incoming messages from broker.
733
+ */
734
+ private handleBrokerMessage(state: BrokerState, data: WebSocket.RawData): void {
735
+ let message: JsonRpcRequest | JsonRpcResponse | JsonRpcNotification;
736
+ try {
737
+ message = JSON.parse(data.toString());
738
+ } catch {
739
+ return; // Invalid JSON - ignore
740
+ }
741
+
742
+ // Handle responses (has id, has result or error)
743
+ if ('id' in message && ('result' in message || 'error' in message)) {
744
+ this.handleBrokerResponse(state, message as JsonRpcResponse);
745
+ return;
746
+ }
747
+
748
+ // Handle requests (has id, has method) - tool invocations from broker
749
+ if ('id' in message && 'method' in message) {
750
+ this.handleBrokerRequest(state, message as JsonRpcRequest);
751
+ return;
752
+ }
753
+
754
+ // Handle notifications (no id, has method)
755
+ if (!('id' in message) && 'method' in message) {
756
+ const notification = message as JsonRpcNotification;
757
+
758
+ // Route kadi.ability.response notifications to pending invocations
759
+ if (notification.method === 'kadi.ability.response') {
760
+ this.handleAbilityResponse(state, notification);
761
+ }
762
+ // Route kadi.event.delivery notifications to event handlers
763
+ else if (notification.method === 'kadi.event.delivery') {
764
+ this.handleEventDelivery(state, notification);
765
+ }
766
+ }
767
+ }
768
+
769
+ /**
770
+ * Handle kadi.ability.response notification.
771
+ *
772
+ * This is the second part of the async tool invocation pattern:
773
+ * 1. Client sends kadi.ability.request → gets { status: 'pending', requestId }
774
+ * 2. Provider executes tool and sends result back to broker
775
+ * 3. Broker sends this notification with the actual result
776
+ */
777
+ private handleAbilityResponse(state: BrokerState, notification: JsonRpcNotification): void {
778
+ const params = notification.params as {
779
+ requestId?: string;
780
+ result?: unknown;
781
+ error?: string | { code?: number; message: string };
782
+ };
783
+
784
+ if (!params?.requestId) {
785
+ return;
786
+ }
787
+
788
+ const pending = state.pendingInvocations.get(params.requestId);
789
+ if (!pending) {
790
+ // No listener - response is orphaned (timed out or unknown requestId)
791
+ return;
792
+ }
793
+
794
+ clearTimeout(pending.timeout);
795
+ state.pendingInvocations.delete(params.requestId);
796
+ this.resolveAbilityResponse(pending, params, state.name);
797
+ }
798
+
799
+ /**
800
+ * Resolve or reject a pending invocation based on response content.
801
+ */
802
+ private resolveAbilityResponse(
803
+ pending: { resolve: (value: unknown) => void; reject: (error: Error) => void; toolName: string },
804
+ response: { result?: unknown; error?: string | { code?: number; message: string } },
805
+ brokerName: string
806
+ ): void {
807
+ if (response.error) {
808
+ const errorMessage = typeof response.error === 'string' ? response.error : response.error.message;
809
+ const errorCode = typeof response.error === 'object' ? response.error.code : undefined;
810
+ pending.reject(
811
+ new KadiError(`Tool "${pending.toolName}" failed: ${errorMessage}`, 'TOOL_INVOCATION_FAILED', {
812
+ toolName: pending.toolName,
813
+ broker: brokerName,
814
+ errorCode,
815
+ })
816
+ );
817
+ } else {
818
+ pending.resolve(response.result);
819
+ }
820
+ }
821
+
822
+ /**
823
+ * Get a connected broker's state, throwing if not available.
824
+ */
825
+ private getConnectedBrokerState(optionsBroker?: string): { state: BrokerState; brokerName: string } {
826
+ const brokerName = optionsBroker ?? this.config.defaultBroker;
827
+ if (!brokerName) {
828
+ throw new KadiError('No broker specified and no defaultBroker configured', 'INVALID_CONFIG', {
829
+ hint: 'Either specify a broker in options or set defaultBroker in config',
830
+ });
831
+ }
832
+ const state = this.brokers.get(brokerName);
833
+ if (!state || state.status !== 'connected') {
834
+ throw new KadiError(`Broker "${brokerName}" is not connected`, 'BROKER_NOT_CONNECTED', {
835
+ broker: brokerName,
836
+ status: state?.status ?? 'not found',
837
+ hint: 'Call client.connect() first',
838
+ });
839
+ }
840
+ return { state, brokerName };
841
+ }
842
+
843
+ // ═══════════════════════════════════════════════════════════════
844
+ // BROKER EVENTS (Pub/Sub)
845
+ // ═══════════════════════════════════════════════════════════════
846
+
847
+ /**
848
+ * Handle kadi.event.delivery notification from broker.
849
+ * Dispatches to local handlers matching the event channel.
850
+ */
851
+ private handleEventDelivery(state: BrokerState, notification: JsonRpcNotification): void {
852
+ const params = notification.params as BrokerEvent | undefined;
853
+
854
+ // Validate notification has required fields
855
+ if (!params?.channel || params.networkId === undefined) {
856
+ // Malformed notification - ignore
857
+ return;
858
+ }
859
+
860
+ // Create the event object to pass to handlers
861
+ const event: BrokerEvent = {
862
+ channel: params.channel,
863
+ data: params.data,
864
+ networkId: params.networkId,
865
+ source: params.source ?? 'unknown',
866
+ timestamp: params.timestamp ?? Date.now(),
867
+ };
868
+
869
+ // Dispatch to all matching handlers
870
+ // We need to check which patterns match this channel
871
+ for (const [pattern, handlers] of state.eventHandlers) {
872
+ if (this.patternMatchesChannel(pattern, event.channel)) {
873
+ for (const handler of handlers) {
874
+ // Fire-and-forget - don't await, don't let one handler block others
875
+ try {
876
+ const result = handler(event);
877
+ // If handler returns a promise, catch errors but don't block
878
+ if (result instanceof Promise) {
879
+ result.catch((err) => {
880
+ // Log but don't throw - one handler error shouldn't affect others
881
+ console.error(`[KADI] Event handler error for pattern "${pattern}":`, err);
882
+ });
883
+ }
884
+ } catch (err) {
885
+ // Sync error in handler
886
+ console.error(`[KADI] Event handler error for pattern "${pattern}":`, err);
887
+ }
888
+ }
889
+ }
890
+ }
891
+ }
892
+
893
+ /**
894
+ * Check if a subscription pattern matches an event channel.
895
+ *
896
+ * Pattern matching follows RabbitMQ topic exchange rules:
897
+ * - '*' matches exactly one word (between dots)
898
+ * - '#' matches zero or more words
899
+ * - Literal strings match exactly
900
+ *
901
+ * Examples:
902
+ * - 'user.*' matches 'user.login', 'user.logout', NOT 'user.profile.update'
903
+ * - 'user.#' matches 'user.login', 'user.profile.update', even just 'user'
904
+ * - 'user.login' matches only 'user.login'
905
+ *
906
+ * @param pattern - The subscription pattern (e.g., 'user.*')
907
+ * @param channel - The event channel (e.g., 'user.login')
908
+ */
909
+ private patternMatchesChannel(pattern: string, channel: string): boolean {
910
+ const patternParts = pattern.split('.');
911
+ const channelParts = channel.split('.');
912
+ return this.matchPatternRecursive(patternParts, 0, channelParts, 0);
913
+ }
914
+
915
+ /**
916
+ * Recursive pattern matcher (RabbitMQ topic exchange style).
917
+ *
918
+ * Example: pattern "user.#.status" vs channel "user.profile.settings.status"
919
+ * → # eats "profile.settings" → match!
920
+ */
921
+ private matchPatternRecursive(
922
+ pattern: string[],
923
+ pi: number,
924
+ channel: string[],
925
+ ci: number
926
+ ): boolean {
927
+ // Both exhausted = match
928
+ if (pi === pattern.length && ci === channel.length) return true;
929
+
930
+ // Pattern done but channel has more = no match
931
+ if (pi === pattern.length) return false;
932
+
933
+ const p = pattern[pi];
934
+
935
+ if (p === '#') {
936
+ // '#' matches zero or more words - try zero first, then one-at-a-time
937
+ if (this.matchPatternRecursive(pattern, pi + 1, channel, ci)) return true;
938
+ if (ci < channel.length && this.matchPatternRecursive(pattern, pi, channel, ci + 1)) return true;
939
+ return false;
940
+ }
941
+
942
+ // Channel exhausted but pattern needs more = no match
943
+ if (ci === channel.length) return false;
944
+
945
+ if (p === '*') {
946
+ // '*' matches exactly one word
947
+ return this.matchPatternRecursive(pattern, pi + 1, channel, ci + 1);
948
+ }
949
+
950
+ // Literal must match exactly
951
+ return p === channel[ci] && this.matchPatternRecursive(pattern, pi + 1, channel, ci + 1);
952
+ }
953
+
954
+ /**
955
+ * Subscribe to events matching a pattern.
956
+ *
957
+ * Pattern matching follows RabbitMQ topic exchange rules:
958
+ * - '*' matches exactly one word (between dots)
959
+ * - '#' matches zero or more words
960
+ *
961
+ * @param pattern - Pattern to subscribe to (e.g., 'user.*', 'order.#')
962
+ * @param handler - Function called when matching event is received
963
+ * @param options - Optional: which broker to subscribe through
964
+ *
965
+ * @example
966
+ * ```typescript
967
+ * // Subscribe to all user events
968
+ * client.subscribe('user.*', (event) => {
969
+ * console.log(`User event: ${event.channel}`, event.data);
970
+ * });
971
+ *
972
+ * // Subscribe to all order events at any depth
973
+ * client.subscribe('order.#', (event) => {
974
+ * console.log(`Order event: ${event.channel}`, event.data);
975
+ * });
976
+ * ```
977
+ */
978
+ async subscribe(
979
+ pattern: string,
980
+ handler: BrokerEventHandler,
981
+ options: SubscribeOptions = {}
982
+ ): Promise<void> {
983
+ const { state } = this.getConnectedBrokerState(options.broker);
984
+
985
+ // Add handler to local tracking
986
+ let handlers = state.eventHandlers.get(pattern);
987
+ if (!handlers) {
988
+ handlers = new Set();
989
+ state.eventHandlers.set(pattern, handlers);
990
+ }
991
+ handlers.add(handler);
992
+
993
+ // If this is the first handler for this pattern, subscribe on broker
994
+ if (!state.subscribedPatterns.has(pattern)) {
995
+ await this.sendRequest(state, {
996
+ jsonrpc: '2.0',
997
+ id: this.nextRequestId++,
998
+ method: 'kadi.event.subscribe',
999
+ params: { pattern },
1000
+ });
1001
+ state.subscribedPatterns.add(pattern);
1002
+ }
1003
+ }
1004
+
1005
+ /**
1006
+ * Unsubscribe from events.
1007
+ *
1008
+ * Removes the specified handler from the pattern. If no handlers remain
1009
+ * for a pattern, the broker subscription is also removed.
1010
+ *
1011
+ * @param pattern - Pattern to unsubscribe from
1012
+ * @param handler - The handler function to remove
1013
+ * @param options - Optional: which broker to unsubscribe from
1014
+ *
1015
+ * @example
1016
+ * ```typescript
1017
+ * const handler = (event) => console.log(event);
1018
+ * client.subscribe('user.*', handler);
1019
+ * // ... later
1020
+ * client.unsubscribe('user.*', handler);
1021
+ * ```
1022
+ */
1023
+ async unsubscribe(
1024
+ pattern: string,
1025
+ handler: BrokerEventHandler,
1026
+ options: SubscribeOptions = {}
1027
+ ): Promise<void> {
1028
+ // Resolve which broker to use
1029
+ const brokerName = options.broker ?? this.config.defaultBroker;
1030
+ if (!brokerName) {
1031
+ throw new KadiError('No broker specified and no defaultBroker configured', 'INVALID_CONFIG', {
1032
+ hint: 'Either specify a broker in options or set defaultBroker in config',
1033
+ });
1034
+ }
1035
+
1036
+ // Get broker state
1037
+ const state = this.brokers.get(brokerName);
1038
+ if (!state) {
1039
+ // Broker not found - nothing to unsubscribe
1040
+ return;
1041
+ }
1042
+
1043
+ // Remove handler from local tracking
1044
+ const handlers = state.eventHandlers.get(pattern);
1045
+ if (handlers) {
1046
+ handlers.delete(handler);
1047
+
1048
+ // If no more handlers for this pattern, clean up
1049
+ if (handlers.size === 0) {
1050
+ state.eventHandlers.delete(pattern);
1051
+
1052
+ // Unsubscribe from broker if we were subscribed
1053
+ if (state.subscribedPatterns.has(pattern) && state.status === 'connected') {
1054
+ try {
1055
+ await this.sendRequest(state, {
1056
+ jsonrpc: '2.0',
1057
+ id: this.nextRequestId++,
1058
+ method: 'kadi.event.unsubscribe',
1059
+ params: { pattern },
1060
+ });
1061
+ } catch {
1062
+ // Ignore errors during unsubscribe (broker might be disconnecting)
1063
+ }
1064
+ state.subscribedPatterns.delete(pattern);
1065
+ }
1066
+ }
1067
+ }
1068
+ }
1069
+
1070
+ /**
1071
+ * Publish an event to a channel.
1072
+ *
1073
+ * Events are published to a specific network. All agents subscribed to
1074
+ * matching patterns on that network will receive the event.
1075
+ *
1076
+ * @param channel - Channel/topic to publish to (e.g., 'user.login')
1077
+ * @param data - Event payload (any JSON-serializable data)
1078
+ * @param options - Optional: network and broker to publish through
1079
+ *
1080
+ * @example
1081
+ * ```typescript
1082
+ * // Publish to default network
1083
+ * await client.publish('user.login', { userId: '123', timestamp: Date.now() });
1084
+ *
1085
+ * // Publish to specific network
1086
+ * await client.publish('order.created', orderData, { network: 'internal' });
1087
+ * ```
1088
+ */
1089
+ async publish(channel: string, data: unknown, options: PublishOptions = {}): Promise<void> {
1090
+ const { state } = this.getConnectedBrokerState(options.broker);
1091
+
1092
+ // Resolve which network to publish to
1093
+ const networkId = options.network ?? this.config.networks[0] ?? 'global';
1094
+
1095
+ // Send publish request to broker
1096
+ await this.sendRequest(state, {
1097
+ jsonrpc: '2.0',
1098
+ id: this.nextRequestId++,
1099
+ method: 'kadi.event.publish',
1100
+ params: {
1101
+ channel,
1102
+ data,
1103
+ networkId,
1104
+ },
1105
+ });
1106
+ }
1107
+
1108
+ // ─────────────────────────────────────────────────────────────
1109
+ // DEPLOYMENT SECRETS (Trusted Introducer Pattern)
1110
+ // ─────────────────────────────────────────────────────────────
1111
+
1112
+ /**
1113
+ * Request secrets from the deployer during agent startup.
1114
+ *
1115
+ * This implements the agent side of the "Trusted Introducer" pattern:
1116
+ * 1. Check for KADI_DEPLOY_NONCE env var (set by deployer)
1117
+ * 2. Subscribe to secrets.response.{agentId} channel
1118
+ * 3. Publish request to secrets.request with nonce and public key
1119
+ * 4. Wait for response from deployer (approved with encrypted secrets, or rejected)
1120
+ * 5. If approved, decrypt using agent's encryption key pair
1121
+ * 6. Set secrets in process.env
1122
+ *
1123
+ * The deployer (kadi deploy) stays connected after deployment and waits
1124
+ * for this request. It verifies the nonce, prompts for approval, and
1125
+ * either shares encrypted secrets or sends a rejection.
1126
+ *
1127
+ * @param options - Configuration options
1128
+ * @returns Decrypted secrets as key-value pairs, or null if no secrets needed
1129
+ * @throws KadiError if timeout waiting for secrets or decryption fails
1130
+ *
1131
+ * @example
1132
+ * ```typescript
1133
+ * const client = new KadiClient({ name: 'my-agent', ... });
1134
+ * await client.connect();
1135
+ *
1136
+ * // Request secrets from deployer (throws if timeout)
1137
+ * const secrets = await client.requestSecrets();
1138
+ *
1139
+ * if (secrets) {
1140
+ * console.log('Received secrets:', Object.keys(secrets));
1141
+ * // Secrets are also set in process.env
1142
+ * }
1143
+ *
1144
+ * // With custom timeout
1145
+ * const secrets = await client.requestSecrets({ timeout: 120000 });
1146
+ * ```
1147
+ */
1148
+ async requestSecrets(options: { timeout?: number } = {}): Promise<Record<string, string> | null> {
1149
+ const { timeout = 60000 } = options; // Default 60 seconds
1150
+
1151
+ // Check for deployment nonce - if not set, no secrets needed
1152
+ const nonce = process.env.KADI_DEPLOY_NONCE;
1153
+ if (!nonce) {
1154
+ return null;
1155
+ }
1156
+
1157
+ // Parse required/optional secrets from env
1158
+ const requiredSecrets = process.env.KADI_REQUIRED_SECRETS?.split(',').filter(Boolean) || [];
1159
+ const optionalSecrets = process.env.KADI_OPTIONAL_SECRETS?.split(',').filter(Boolean) || [];
1160
+
1161
+ if (requiredSecrets.length === 0 && optionalSecrets.length === 0) {
1162
+ return null;
1163
+ }
1164
+
1165
+ // Ensure we're connected to at least one broker
1166
+ const connectedBrokers: [string, BrokerState][] = [];
1167
+ for (const [name, state] of this.brokers.entries()) {
1168
+ if (state.status === 'connected') {
1169
+ connectedBrokers.push([name, state]);
1170
+ }
1171
+ }
1172
+
1173
+ if (connectedBrokers.length === 0) {
1174
+ throw new KadiError(
1175
+ 'Cannot request secrets: not connected to any broker',
1176
+ 'SECRETS_NOT_CONNECTED',
1177
+ { hint: 'Call client.connect() before requestSecrets()' }
1178
+ );
1179
+ }
1180
+
1181
+ // Determine which broker to use for the secret handshake
1182
+ let brokerName: string;
1183
+ const rendezvousBrokerUrl = process.env.KADI_RENDEZVOUS_BROKER;
1184
+
1185
+ if (rendezvousBrokerUrl) {
1186
+ // Find the broker matching the rendezvous URL
1187
+ const match = connectedBrokers.find(([_, state]) => state.url === rendezvousBrokerUrl);
1188
+
1189
+ if (!match) {
1190
+ const connectedUrlsFormatted = connectedBrokers.map(([name, state]) => ` - ${name}: ${state.url}`).join('\n');
1191
+ throw new KadiError(
1192
+ `This agent needs secrets, but it's not connected to the right broker.`,
1193
+ 'SECRETS_NOT_CONNECTED',
1194
+ {
1195
+ hint: `Secrets will be delivered on:\n ${rendezvousBrokerUrl}\n\nYour client is connected to:\n${connectedUrlsFormatted}\n\nTo fix, add the delivery broker URL to your KadiClient config,\nor update agent.json brokers to include this URL.`,
1196
+ }
1197
+ );
1198
+ }
1199
+
1200
+ brokerName = match[0];
1201
+ } else {
1202
+ // No rendezvous broker specified - use first connected (for dev/testing)
1203
+ brokerName = connectedBrokers[0]![0];
1204
+ }
1205
+
1206
+ return new Promise((resolve, reject) => {
1207
+ let resolved = false;
1208
+ let timeoutHandle: NodeJS.Timeout | undefined;
1209
+
1210
+ // Cleanup function
1211
+ const cleanup = () => {
1212
+ if (timeoutHandle) {
1213
+ clearTimeout(timeoutHandle);
1214
+ }
1215
+ // Unsubscribe from the channel
1216
+ this.unsubscribe(`secrets.response.${this._agentId}`, handleSecrets, { broker: brokerName })
1217
+ .catch(() => {}); // Ignore unsubscribe errors
1218
+ };
1219
+
1220
+ // Handler for receiving secrets response
1221
+ const handleSecrets: BrokerEventHandler = async (event: BrokerEvent) => {
1222
+ if (resolved) return;
1223
+
1224
+ // Extract response data from event
1225
+ const data = event.data as {
1226
+ status?: 'approved' | 'rejected';
1227
+ encrypted?: string;
1228
+ reason?: string;
1229
+ sharedAt?: string;
1230
+ rejectedAt?: string;
1231
+ } | undefined;
1232
+
1233
+ if (!data?.status) {
1234
+ // Malformed response - ignore
1235
+ return;
1236
+ }
1237
+
1238
+ // Validate status is expected value
1239
+ if (data.status !== 'approved' && data.status !== 'rejected') {
1240
+ // Unknown status - ignore malformed response
1241
+ return;
1242
+ }
1243
+
1244
+ resolved = true;
1245
+ cleanup();
1246
+
1247
+ // Handle rejection
1248
+ if (data.status === 'rejected') {
1249
+ reject(new KadiError(
1250
+ `Secrets request rejected: ${data.reason || 'No reason provided'}`,
1251
+ 'SECRETS_REJECTED',
1252
+ { reason: data.reason }
1253
+ ));
1254
+ return;
1255
+ }
1256
+
1257
+ // Handle approval - must have encrypted data
1258
+ if (!data.encrypted) {
1259
+ reject(new KadiError(
1260
+ 'Secrets approved but no encrypted data received',
1261
+ 'SECRETS_DECRYPTION_FAILED',
1262
+ { hint: 'The deployer may have encountered an error' }
1263
+ ));
1264
+ return;
1265
+ }
1266
+
1267
+ try {
1268
+ // Decode base64 encrypted blob
1269
+ const encryptedBytes = Buffer.from(data.encrypted, 'base64');
1270
+
1271
+ // Get our encryption key pair
1272
+ const keyPair = this.encryptionKeyPair;
1273
+
1274
+ // Decrypt the sealed box
1275
+ const decrypted = sealedbox.open(
1276
+ new Uint8Array(encryptedBytes),
1277
+ keyPair.publicKey,
1278
+ keyPair.secretKey
1279
+ );
1280
+
1281
+ if (!decrypted) {
1282
+ reject(new KadiError(
1283
+ 'Failed to decrypt secrets: invalid sealed box or wrong key',
1284
+ 'SECRETS_DECRYPTION_FAILED',
1285
+ { hint: 'The secrets may have been encrypted for a different agent' }
1286
+ ));
1287
+ return;
1288
+ }
1289
+
1290
+ // Parse JSON
1291
+ const secretsJson = new TextDecoder().decode(decrypted);
1292
+ const secrets: Record<string, string> = JSON.parse(secretsJson);
1293
+
1294
+ // Set secrets in process.env
1295
+ for (const [key, value] of Object.entries(secrets)) {
1296
+ process.env[key] = value;
1297
+ }
1298
+
1299
+ resolve(secrets);
1300
+ } catch (err) {
1301
+ reject(new KadiError(
1302
+ `Failed to decrypt secrets: ${(err as Error).message}`,
1303
+ 'SECRETS_DECRYPTION_FAILED',
1304
+ { cause: err, hint: 'The secrets may have been encrypted for a different agent' }
1305
+ ));
1306
+ }
1307
+ };
1308
+
1309
+ // Set timeout
1310
+ timeoutHandle = setTimeout(() => {
1311
+ if (!resolved) {
1312
+ resolved = true;
1313
+ cleanup();
1314
+ reject(new KadiError(
1315
+ `Timeout waiting for secrets (${timeout / 1000}s). Deployer may have disconnected or denied the request.`,
1316
+ 'SECRETS_TIMEOUT',
1317
+ {
1318
+ timeout,
1319
+ hint: 'Ensure the deployer is still running and approves the secret request',
1320
+ }
1321
+ ));
1322
+ }
1323
+ }, timeout);
1324
+
1325
+ // Subscribe to our response channel
1326
+ this.subscribe(`secrets.response.${this._agentId}`, handleSecrets, { broker: brokerName })
1327
+ .then(() => {
1328
+ // Publish secret request
1329
+ return this.publish('secrets.request', {
1330
+ nonce,
1331
+ agentId: this._agentId,
1332
+ publicKey: this._publicKeyBase64,
1333
+ required: requiredSecrets,
1334
+ optional: optionalSecrets,
1335
+ }, { broker: brokerName });
1336
+ })
1337
+ .catch((err) => {
1338
+ if (!resolved) {
1339
+ resolved = true;
1340
+ cleanup();
1341
+ reject(new KadiError(
1342
+ `Failed to request secrets: ${(err as Error).message}`,
1343
+ 'SECRETS_REQUEST_FAILED',
1344
+ { cause: err }
1345
+ ));
1346
+ }
1347
+ });
1348
+ });
1349
+ }
1350
+
1351
+ /**
1352
+ * Handle incoming request from broker (tool invocation).
1353
+ */
1354
+ private async handleBrokerRequest(state: BrokerState, request: JsonRpcRequest): Promise<void> {
1355
+ // Handle tool invocation: kadi.ability.request
1356
+ if (request.method === 'kadi.ability.request') {
1357
+ const params = request.params as {
1358
+ toolName: string;
1359
+ toolInput: unknown;
1360
+ _meta?: {
1361
+ callerProtocol?: 'mcp' | 'kadi';
1362
+ kadi_caller?: {
1363
+ sessionId?: string;
1364
+ publicKey?: string;
1365
+ };
1366
+ network?: string;
1367
+ };
1368
+ };
1369
+
1370
+ // Build request context from broker metadata
1371
+ // Only include optional fields if present (cleaner objects, matches Python SDK)
1372
+ const context: RequestContext = {
1373
+ broker: state.name,
1374
+ requestId: String(request.id),
1375
+ };
1376
+ if (params._meta?.network) context.network = params._meta.network;
1377
+ if (params._meta?.callerProtocol) context.callerProtocol = params._meta.callerProtocol;
1378
+ if (params._meta?.kadi_caller?.sessionId) context.callerSessionId = params._meta.kadi_caller.sessionId;
1379
+ if (params._meta?.kadi_caller?.publicKey) context.callerPublicKey = params._meta.kadi_caller.publicKey;
1380
+
1381
+ await this.handleInvokeRequest(state, request.id, params.toolName, params.toolInput, context);
1382
+ }
1383
+ }
1384
+
1385
+ /**
1386
+ * Handle incoming tool invocation request from broker.
1387
+ *
1388
+ * When callerProtocol is 'mcp', responses are formatted for MCP clients:
1389
+ * - If result is already MCP-shaped (has valid content array), pass through unchanged
1390
+ * - Otherwise, wrap as text: { content: [{ type: 'text', text: '...' }], isError: false }
1391
+ *
1392
+ * This allows tools to return images, audio, or other MCP content types directly,
1393
+ * while plain JSON results are automatically wrapped as text.
1394
+ *
1395
+ * When callerProtocol is 'kadi' or undefined, raw structured data is returned.
1396
+ */
1397
+ private async handleInvokeRequest(
1398
+ state: BrokerState,
1399
+ requestId: string | number,
1400
+ toolName: string,
1401
+ toolInput: unknown,
1402
+ context: RequestContext
1403
+ ): Promise<void> {
1404
+ try {
1405
+ const result = await this.executeToolHandler(toolName, toolInput, context);
1406
+
1407
+ // Format response based on caller protocol
1408
+ let responseResult: unknown;
1409
+ if (context.callerProtocol === 'mcp') {
1410
+ // MCP clients expect CallToolResult format.
1411
+ // If the result is already MCP-shaped, pass through unchanged.
1412
+ // Otherwise, wrap as text content.
1413
+ responseResult = isMcpCallToolResult(result)
1414
+ ? result
1415
+ : { content: [{ type: 'text', text: JSON.stringify(result) }], isError: false };
1416
+ } else {
1417
+ // KADI clients receive raw structured data
1418
+ responseResult = result;
1419
+ }
1420
+
1421
+ const response: JsonRpcResponse = {
1422
+ jsonrpc: '2.0',
1423
+ id: requestId,
1424
+ result: responseResult,
1425
+ };
1426
+ state.ws?.send(JSON.stringify(response));
1427
+ } catch (error) {
1428
+ const errorMessage = error instanceof Error ? error.message : String(error);
1429
+
1430
+ // For MCP clients, wrap errors in CallToolResult format too
1431
+ if (context.callerProtocol === 'mcp') {
1432
+ const response: JsonRpcResponse = {
1433
+ jsonrpc: '2.0',
1434
+ id: requestId,
1435
+ result: { content: [{ type: 'text', text: errorMessage }], isError: true },
1436
+ };
1437
+ state.ws?.send(JSON.stringify(response));
1438
+ } else {
1439
+ // KADI clients receive JSON-RPC error
1440
+ const response: JsonRpcResponse = {
1441
+ jsonrpc: '2.0',
1442
+ id: requestId,
1443
+ error: {
1444
+ code: -32000,
1445
+ message: errorMessage,
1446
+ },
1447
+ };
1448
+ state.ws?.send(JSON.stringify(response));
1449
+ }
1450
+ }
1451
+ }
1452
+
1453
+ /**
1454
+ * Handle response to a pending request.
1455
+ */
1456
+ private handleBrokerResponse(state: BrokerState, response: JsonRpcResponse): void {
1457
+ const pending = state.pendingRequests.get(response.id);
1458
+ if (!pending) {
1459
+ return; // No one waiting for this response
1460
+ }
1461
+
1462
+ // Clean up: remove from pending and clear timeout
1463
+ clearTimeout(pending.timeout);
1464
+ state.pendingRequests.delete(response.id);
1465
+
1466
+ if (response.error) {
1467
+ pending.reject(
1468
+ new KadiError(response.error.message, 'BROKER_ERROR', {
1469
+ code: response.error.code,
1470
+ broker: state.name,
1471
+ })
1472
+ );
1473
+ } else {
1474
+ pending.resolve(response.result);
1475
+ }
1476
+ }
1477
+
1478
+ /**
1479
+ * Send heartbeat to keep connection alive.
1480
+ */
1481
+ private sendHeartbeat(state: BrokerState): void {
1482
+ if (state.ws?.readyState === WebSocket.OPEN) {
1483
+ const heartbeat = this.buildHeartbeatMessage();
1484
+ state.ws.send(JSON.stringify(heartbeat));
1485
+ }
1486
+ }
1487
+
1488
+ /**
1489
+ * Cleanup broker connection state.
1490
+ * Called on disconnect or connection failure.
1491
+ */
1492
+ private cleanupBroker(state: BrokerState): void {
1493
+ // Stop heartbeat
1494
+ if (state.heartbeatTimer) {
1495
+ clearInterval(state.heartbeatTimer);
1496
+ state.heartbeatTimer = null;
1497
+ }
1498
+
1499
+ // Cancel any pending reconnect attempt
1500
+ if (state.reconnectTimer) {
1501
+ clearTimeout(state.reconnectTimer);
1502
+ state.reconnectTimer = null;
1503
+ }
1504
+
1505
+ // Reject pending requests (hello, auth, register, etc.) and clear their timeouts
1506
+ for (const [, pending] of state.pendingRequests) {
1507
+ clearTimeout(pending.timeout);
1508
+ pending.reject(new KadiError('Broker disconnected', 'BROKER_NOT_CONNECTED'));
1509
+ }
1510
+ state.pendingRequests.clear();
1511
+
1512
+ // Reject pending invocations (invokeRemote waiting for results) and clear their timeouts
1513
+ for (const [, pending] of state.pendingInvocations) {
1514
+ clearTimeout(pending.timeout);
1515
+ pending.reject(
1516
+ new KadiError(`Tool "${pending.toolName}" failed: broker disconnected`, 'BROKER_NOT_CONNECTED', {
1517
+ broker: state.name,
1518
+ toolName: pending.toolName,
1519
+ })
1520
+ );
1521
+ }
1522
+ state.pendingInvocations.clear();
1523
+ }
1524
+
1525
+ // ─────────────────────────────────────────────────────────────
1526
+ // RECONNECTION LOGIC
1527
+ // ─────────────────────────────────────────────────────────────
1528
+
1529
+ /**
1530
+ * Handle WebSocket close event.
1531
+ *
1532
+ * Decides whether to attempt reconnection based on:
1533
+ * - Was the connection ever established? (don't reconnect on initial failure)
1534
+ * - autoReconnect config setting
1535
+ * - Whether this was an intentional disconnect (user called disconnect())
1536
+ */
1537
+ private handleWebSocketClose(state: BrokerState): void {
1538
+ // Capture the status before cleanup changes it
1539
+ const wasConnected = state.status === 'connected';
1540
+
1541
+ // Clean up connection resources (heartbeat, pending requests)
1542
+ this.cleanupBroker(state);
1543
+
1544
+ // If user intentionally disconnected, don't reconnect
1545
+ if (state.intentionalDisconnect) {
1546
+ state.status = 'disconnected';
1547
+ return;
1548
+ }
1549
+
1550
+ // If the connection was never established (initial connection failed),
1551
+ // don't reconnect - let the error propagate to the caller
1552
+ if (!wasConnected && state.reconnectAttempts === 0) {
1553
+ state.status = 'disconnected';
1554
+ return;
1555
+ }
1556
+
1557
+ // If auto-reconnect is disabled, just mark as disconnected
1558
+ if (!this.config.autoReconnect) {
1559
+ state.status = 'disconnected';
1560
+ console.error(`[KADI] Connection to broker "${state.name}" lost. Auto-reconnect is disabled.`);
1561
+ return;
1562
+ }
1563
+
1564
+ // If we're already in a reconnection attempt (status = 'reconnecting'),
1565
+ // don't schedule another one here. The catch block in attemptReconnect()
1566
+ // will handle rescheduling. This prevents duplicate timers when the
1567
+ // WebSocket closes during a reconnection attempt (e.g., handshake fails).
1568
+ if (state.status === 'reconnecting') {
1569
+ return;
1570
+ }
1571
+
1572
+ // Schedule reconnection attempt
1573
+ this.scheduleReconnect(state);
1574
+ }
1575
+
1576
+ /**
1577
+ * Calculate reconnection delay using exponential backoff with jitter.
1578
+ *
1579
+ * Formula: min(baseDelay * 2^attempt, maxDelay) ± 20% jitter
1580
+ *
1581
+ * Examples (with maxDelay=30s):
1582
+ * - Attempt 0: ~1s (1000ms ± 200ms)
1583
+ * - Attempt 1: ~2s (2000ms ± 400ms)
1584
+ * - Attempt 2: ~4s (4000ms ± 800ms)
1585
+ * - Attempt 3: ~8s
1586
+ * - Attempt 4: ~16s
1587
+ * - Attempt 5+: ~30s (capped)
1588
+ *
1589
+ * Why jitter? When a broker restarts and 100 agents try to reconnect,
1590
+ * jitter spreads them out so they don't all hit at once (thundering herd).
1591
+ */
1592
+ private getReconnectDelay(attempt: number): number {
1593
+ // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s...
1594
+ const exponentialDelay = RECONNECT_BASE_DELAY * Math.pow(2, attempt);
1595
+
1596
+ // Cap at maximum delay
1597
+ const cappedDelay = Math.min(exponentialDelay, this.config.maxReconnectDelay);
1598
+
1599
+ // Add jitter: ±20% randomization
1600
+ // (Math.random() * 2 - 1) gives a value between -1 and 1
1601
+ const jitterFactor = 0.2;
1602
+ const jitter = cappedDelay * jitterFactor * (Math.random() * 2 - 1);
1603
+
1604
+ return Math.round(cappedDelay + jitter);
1605
+ }
1606
+
1607
+ /**
1608
+ * Schedule a reconnection attempt.
1609
+ *
1610
+ * Logs the attempt number and delay for visibility.
1611
+ *
1612
+ * Note: Reconnection continues indefinitely until successful or
1613
+ * disconnect() is called. There is no max attempt limit - only
1614
+ * the delay is capped at maxReconnectDelay.
1615
+ */
1616
+ private scheduleReconnect(state: BrokerState): void {
1617
+ state.status = 'reconnecting';
1618
+ state.reconnectAttempts++;
1619
+
1620
+ const delay = this.getReconnectDelay(state.reconnectAttempts - 1);
1621
+
1622
+ console.error(
1623
+ `[KADI] Connection to broker "${state.name}" lost. ` +
1624
+ `Reconnecting in ${(delay / 1000).toFixed(1)}s (attempt ${state.reconnectAttempts})...`
1625
+ );
1626
+
1627
+ state.reconnectTimer = setTimeout(() => {
1628
+ this.attemptReconnect(state);
1629
+ }, delay);
1630
+ }
1631
+
1632
+ /**
1633
+ * Attempt to reconnect to the broker.
1634
+ *
1635
+ * Uses the client's identity (same keypair/agentId for all connections)
1636
+ * and re-registers tools with the broker.
1637
+ *
1638
+ * On failure, schedules another attempt with increased delay.
1639
+ */
1640
+ private async attemptReconnect(state: BrokerState): Promise<void> {
1641
+ // Guard against orphaned timers firing after successful reconnection
1642
+ // or after intentional disconnect was requested
1643
+ if (state.status === 'connected' || state.intentionalDisconnect) {
1644
+ return;
1645
+ }
1646
+
1647
+ state.reconnectTimer = null;
1648
+
1649
+ try {
1650
+ // Reopen WebSocket connection
1651
+ await this.openWebSocket(state);
1652
+
1653
+ // Re-authenticate with existing keypair
1654
+ await this.performHandshake(state);
1655
+
1656
+ // Re-register tools
1657
+ await this.registerWithBroker(state);
1658
+
1659
+ // Restart heartbeat
1660
+ state.heartbeatTimer = setInterval(() => {
1661
+ this.sendHeartbeat(state);
1662
+ }, this.config.heartbeatInterval);
1663
+
1664
+ // Success!
1665
+ console.error(`[KADI] Reconnected to broker "${state.name}" after ${state.reconnectAttempts} attempts`);
1666
+ state.reconnectAttempts = 0;
1667
+ state.status = 'connected';
1668
+ } catch (error) {
1669
+ // Log the error and try again
1670
+ const message = error instanceof Error ? error.message : String(error);
1671
+ console.error(`[KADI] Reconnection to broker "${state.name}" failed: ${message}`);
1672
+
1673
+ // If still not intentionally disconnected, schedule another attempt
1674
+ if (!state.intentionalDisconnect) {
1675
+ this.scheduleReconnect(state);
1676
+ }
1677
+ }
1678
+ }
1679
+
1680
+ /**
1681
+ * Disconnect from broker(s).
1682
+ *
1683
+ * @param brokerName - If provided, disconnect only from that broker.
1684
+ * If not provided, disconnect from all brokers.
1685
+ */
1686
+ async disconnect(brokerName?: string): Promise<void> {
1687
+ if (brokerName) {
1688
+ // Disconnect from specific broker
1689
+ const state = this.brokers.get(brokerName);
1690
+ if (state) {
1691
+ this.disconnectBroker(state);
1692
+ this.brokers.delete(brokerName);
1693
+ }
1694
+ } else {
1695
+ // Disconnect from all brokers
1696
+ for (const [_name, state] of this.brokers) {
1697
+ this.disconnectBroker(state);
1698
+ }
1699
+ this.brokers.clear();
1700
+ }
1701
+ }
1702
+
1703
+ /**
1704
+ * Internal helper to disconnect a single broker.
1705
+ *
1706
+ * IMPORTANT: The order of operations matters!
1707
+ * 1. Set intentionalDisconnect BEFORE closing WebSocket
1708
+ * 2. ws.close() triggers handleWebSocketClose(), which checks this flag
1709
+ * 3. If the flag isn't set first, reconnection would be triggered
1710
+ */
1711
+ private disconnectBroker(state: BrokerState): void {
1712
+ state.intentionalDisconnect = true;
1713
+ state.status = 'disconnecting';
1714
+ this.cleanupBroker(state);
1715
+ state.ws?.close();
1716
+ state.status = 'disconnected';
1717
+ }
1718
+
1719
+ // ─────────────────────────────────────────────────────────────
1720
+ // EVENTS
1721
+ // ─────────────────────────────────────────────────────────────
1722
+
1723
+ /**
1724
+ * Set the event handler callback.
1725
+ * Called by native/stdio transport when loading this ability.
1726
+ * @internal
1727
+ */
1728
+ setEventHandler(handler: (event: string, data: unknown) => void): void {
1729
+ this.eventHandler = handler;
1730
+ }
1731
+
1732
+ /**
1733
+ * Emit an event to the consumer that loaded this ability.
1734
+ *
1735
+ * Events are fire-and-forget notifications - the consumer does not
1736
+ * send a response. Use this for real-time updates, state changes,
1737
+ * or any notification that doesn't require a response.
1738
+ *
1739
+ * @param event - Event name (e.g., 'file.changed', 'user.login')
1740
+ * @param data - Event payload
1741
+ * @param options - Optional: specify broker to also broadcast to
1742
+ *
1743
+ * @example
1744
+ * ```typescript
1745
+ * // Emit locally only
1746
+ * client.emit('progress', { percent: 50 });
1747
+ *
1748
+ * // Emit locally AND broadcast to broker subscribers
1749
+ * client.emit('order.created', orderData, { broker: 'local' });
1750
+ * ```
1751
+ */
1752
+ emit(event: string, data: unknown, options?: EmitOptions): void {
1753
+ // Local emit: to parent agent (if loaded as ability)
1754
+ if (this.eventHandler) {
1755
+ // Native transport: direct callback
1756
+ this.eventHandler(event, data);
1757
+ } else if (this.isServingStdio) {
1758
+ // Stdio transport: write notification to stdout
1759
+ const notification = {
1760
+ jsonrpc: '2.0',
1761
+ method: 'event',
1762
+ params: { name: event, data },
1763
+ };
1764
+ const json = JSON.stringify(notification);
1765
+ process.stdout.write(`Content-Length: ${Buffer.byteLength(json)}\r\n\r\n${json}`);
1766
+ }
1767
+
1768
+ // Broadcast: also publish to broker (if specified and connected)
1769
+ if (options?.broker) {
1770
+ const state = this.brokers.get(options.broker);
1771
+ if (state?.status === 'connected') {
1772
+ // Fire-and-forget: don't await, but log errors
1773
+ this.publish(event, data, { broker: options.broker, network: options.network }).catch((err) => {
1774
+ const message = err instanceof Error ? err.message : String(err);
1775
+ console.error(`[KADI] emit() broadcast to broker "${options.broker}" failed: ${message}`);
1776
+ });
1777
+ }
1778
+ }
1779
+ }
1780
+
1781
+ // ─────────────────────────────────────────────────────────────
1782
+ // TOOL REGISTRATION
1783
+ // ─────────────────────────────────────────────────────────────
1784
+
1785
+ /**
1786
+ * Register a tool with this agent.
1787
+ *
1788
+ * @param definition - Tool definition (with Zod schemas)
1789
+ * @param handler - Function to handle invocations
1790
+ * @param options - Registration options (broker targeting)
1791
+ *
1792
+ * @example
1793
+ * ```typescript
1794
+ * client.registerTool({
1795
+ * name: 'add',
1796
+ * description: 'Add two numbers',
1797
+ * input: z.object({ a: z.number(), b: z.number() }),
1798
+ * output: z.object({ result: z.number() }),
1799
+ * }, async ({ a, b }) => ({ result: a + b }));
1800
+ *
1801
+ * // Register only on specific broker
1802
+ * client.registerTool(def, handler, { brokers: ['internal'] });
1803
+ * ```
1804
+ */
1805
+ registerTool<TInput, TOutput>(
1806
+ definition: ZodToolDefinition<TInput, TOutput>,
1807
+ handler: ToolHandler<TInput, TOutput>,
1808
+ options: RegisterToolOptions = {}
1809
+ ): void {
1810
+ // Check for duplicate
1811
+ if (this.tools.has(definition.name)) {
1812
+ throw new KadiError(`Tool "${definition.name}" is already registered`, 'INVALID_CONFIG', {
1813
+ toolName: definition.name,
1814
+ hint: 'Each tool must have a unique name',
1815
+ });
1816
+ }
1817
+
1818
+ // Convert Zod schemas to JSON Schema
1819
+ const jsonDefinition: ToolDefinition = {
1820
+ name: definition.name,
1821
+ description: definition.description,
1822
+ inputSchema: isZodSchema(definition.input) ? zodToJsonSchema(definition.input) : { type: 'object' },
1823
+ outputSchema: definition.output && isZodSchema(definition.output)
1824
+ ? zodToJsonSchema(definition.output)
1825
+ : undefined,
1826
+ };
1827
+
1828
+ // Store registration
1829
+ const registered: RegisteredTool = {
1830
+ definition: jsonDefinition,
1831
+ handler: handler as ToolHandler,
1832
+ registeredAt: new Date(),
1833
+ targetBrokers: options.brokers ?? [],
1834
+ };
1835
+ this.tools.set(definition.name, registered);
1836
+ }
1837
+
1838
+ /**
1839
+ * Get tool definitions, optionally filtered for a specific broker.
1840
+ *
1841
+ * @param forBroker - If provided, only return tools targeted for this broker.
1842
+ * Tools with empty targetBrokers are included for all brokers.
1843
+ */
1844
+ private getToolDefinitions(forBroker?: string): ToolDefinition[] {
1845
+ return Array.from(this.tools.values())
1846
+ .filter((t) => {
1847
+ // If no broker specified, return all tools (e.g., for readAgentJson)
1848
+ if (!forBroker) return true;
1849
+
1850
+ // Empty targetBrokers means "register with all brokers"
1851
+ if (t.targetBrokers.length === 0) return true;
1852
+
1853
+ // Otherwise, only include if this broker is in the target list
1854
+ return t.targetBrokers.includes(forBroker);
1855
+ })
1856
+ .map((t) => t.definition);
1857
+ }
1858
+
1859
+ /**
1860
+ * Execute a tool registered on this client.
1861
+ *
1862
+ * This is a PRIVATE method - not for external use.
1863
+ * It handles INCOMING tool calls from:
1864
+ * - Broker (when another agent calls your tools)
1865
+ * - Stdio (when running as an ability)
1866
+ * - Native transport (when loaded in-process)
1867
+ *
1868
+ * To call tools on OTHER agents, use:
1869
+ * - `loadBroker(name).invoke(tool, params)` - for repeated calls to the same agent
1870
+ * - `invokeRemote(tool, params, { timeout })` - for one-off calls with custom timeout
1871
+ *
1872
+ * @param toolName - Name of the registered tool to execute
1873
+ * @param params - Input parameters for the tool
1874
+ * @param context - Request context with caller info (provided by broker/stdio)
1875
+ * @internal
1876
+ */
1877
+ private async executeToolHandler(toolName: string, params: unknown, context?: RequestContext): Promise<unknown> {
1878
+ const tool = this.tools.get(toolName);
1879
+ if (!tool) {
1880
+ throw new KadiError(`Tool "${toolName}" not found`, 'TOOL_NOT_FOUND', {
1881
+ toolName,
1882
+ available: Array.from(this.tools.keys()),
1883
+ hint: 'Register the tool first with registerTool()',
1884
+ });
1885
+ }
1886
+
1887
+ // TODO: Validate params against tool.definition.input schema before invoking.
1888
+ // Currently the schema is only used for discovery (kadi.ability.list) and TypeScript types.
1889
+ // Tool handlers must do their own validation, which defeats the purpose of requiring a schema.
1890
+ // Should call tool.definition.input.safeParse(params) and throw on validation errors.
1891
+
1892
+ return tool.handler(params, context);
1893
+ }
1894
+
1895
+ /**
1896
+ * Create a bridge for internal transport use.
1897
+ *
1898
+ * This allows native transport to call tools on this client without
1899
+ * exposing the full client interface.
1900
+ *
1901
+ * @internal - Not for external use
1902
+ */
1903
+ createToolBridge(): ToolExecutionBridge {
1904
+ return {
1905
+ executeToolHandler: (toolName, params, context) =>
1906
+ this.executeToolHandler(toolName, params, context),
1907
+ getRegisteredTools: () =>
1908
+ Array.from(this.tools.values()).map((t) => t.definition),
1909
+ };
1910
+ }
1911
+
1912
+ // ─────────────────────────────────────────────────────────────
1913
+ // ABILITY LOADING
1914
+ // ─────────────────────────────────────────────────────────────
1915
+
1916
+ /**
1917
+ * Load an in-process ability via dynamic import.
1918
+ *
1919
+ * @param name - Ability name (resolved from agent-lock.json)
1920
+ * @param options - Optional explicit path
1921
+ *
1922
+ * @example
1923
+ * ```typescript
1924
+ * // Load by name (resolves from agent-lock.json)
1925
+ * const calc = await client.loadNative('calculator');
1926
+ *
1927
+ * // Load by explicit path
1928
+ * const calc = await client.loadNative('calculator', { path: './abilities/calc' });
1929
+ * ```
1930
+ */
1931
+ async loadNative(name: string, options: LoadNativeOptions = {}): Promise<LoadedAbility> {
1932
+ // Resolve path and entrypoint from lock file or use explicit path
1933
+ let path: string;
1934
+ let entrypoint: string | undefined;
1935
+
1936
+ if (options.path) {
1937
+ path = options.path;
1938
+ } else {
1939
+ const entry = await resolveAbilityEntry(name, options.projectRoot);
1940
+ path = entry.absolutePath;
1941
+ entrypoint = entry.entrypoint;
1942
+ }
1943
+
1944
+ return loadNativeTransport(path, entrypoint, {
1945
+ timeout: options.timeout ?? this.config.requestTimeout,
1946
+ });
1947
+ }
1948
+
1949
+ /**
1950
+ * Load a child process ability via stdio.
1951
+ *
1952
+ * Three modes of operation:
1953
+ *
1954
+ * 1. **Explicit mode** — Provide `command` and `args` directly (bypasses agent.json):
1955
+ * ```typescript
1956
+ * await client.loadStdio('analyzer', {
1957
+ * command: 'python3',
1958
+ * args: ['./analyzer/main.py', '--verbose'],
1959
+ * });
1960
+ * ```
1961
+ *
1962
+ * 2. **Script selection mode** — Choose which script from ability's agent.json:
1963
+ * ```typescript
1964
+ * await client.loadStdio('analyzer', { script: 'dev' });
1965
+ * // Uses scripts.dev from the ability's agent.json
1966
+ * ```
1967
+ *
1968
+ * 3. **Default mode** — Uses scripts.start from ability's agent.json:
1969
+ * ```typescript
1970
+ * await client.loadStdio('analyzer');
1971
+ * // Resolves from agent-lock.json, reads scripts.start from agent.json
1972
+ * ```
1973
+ *
1974
+ * @param name - Ability name (used for error messages and lock file lookup)
1975
+ * @param options - Command/args override, or script selection
1976
+ */
1977
+ async loadStdio(name: string, options: LoadStdioOptions = {}): Promise<LoadedAbility> {
1978
+ // ─────────────────────────────────────────────────────────────────────────
1979
+ // Mode 1: Explicit command — bypass agent.json entirely
1980
+ // If command is provided, script option is ignored (explicit mode wins)
1981
+ // ─────────────────────────────────────────────────────────────────────────
1982
+ if (options.command) {
1983
+ return loadStdioTransport(options.command, options.args ?? [], {
1984
+ timeoutMs: options.timeout ?? this.config.requestTimeout,
1985
+ });
1986
+ }
1987
+
1988
+ // ─────────────────────────────────────────────────────────────────────────
1989
+ // Mode 2 & 3: Resolve script from ability's agent.json
1990
+ // ─────────────────────────────────────────────────────────────────────────
1991
+ const { command, args, cwd } = await resolveAbilityScript(
1992
+ name,
1993
+ options.script ?? 'start',
1994
+ options.projectRoot
1995
+ );
1996
+
1997
+ return loadStdioTransport(command, args, {
1998
+ timeoutMs: options.timeout ?? this.config.requestTimeout,
1999
+ cwd, // Run in ability's directory so relative paths work
2000
+ });
2001
+ }
2002
+
2003
+ /**
2004
+ * Load a remote ability via broker.
2005
+ *
2006
+ * @param name - Ability name to discover on broker
2007
+ * @param options - Broker to use, networks to filter
2008
+ *
2009
+ * @example
2010
+ * ```typescript
2011
+ * // Load from default broker
2012
+ * const ai = await client.loadBroker('text-analyzer');
2013
+ *
2014
+ * // Load from specific broker
2015
+ * const ai = await client.loadBroker('text-analyzer', { broker: 'internal' });
2016
+ * ```
2017
+ */
2018
+ async loadBroker(name: string, options: LoadBrokerOptions = {}): Promise<LoadedAbility> {
2019
+ const { state, brokerName } = this.getConnectedBrokerState(options.broker);
2020
+
2021
+ return loadBrokerTransport(name, {
2022
+ broker: state,
2023
+ requestTimeout: options.timeout ?? this.config.requestTimeout,
2024
+ networks: options.networks,
2025
+ // Provide subscribe/unsubscribe for ability.on()/off() support
2026
+ subscribe: (pattern, handler) => this.subscribe(pattern, handler, { broker: brokerName }),
2027
+ unsubscribe: (pattern, handler) => this.unsubscribe(pattern, handler, { broker: brokerName }),
2028
+ });
2029
+ }
2030
+
2031
+ // ─────────────────────────────────────────────────────────────
2032
+ // REMOTE INVOCATION
2033
+ // ─────────────────────────────────────────────────────────────
2034
+
2035
+ /**
2036
+ * Invoke a tool on a remote agent directly (without loading the ability).
2037
+ *
2038
+ * This uses the KADI async invocation pattern:
2039
+ * 1. Send kadi.ability.request → broker immediately returns { status: 'pending', requestId }
2040
+ * 2. Broker forwards request to provider agent
2041
+ * 3. Provider executes tool and sends result back to broker
2042
+ * 4. Broker sends kadi.ability.response notification with the actual result
2043
+ *
2044
+ * @param toolName - Tool name (e.g., "add"). Broker routes to any provider.
2045
+ * @param params - Tool parameters
2046
+ * @param options - Broker to use, timeout
2047
+ *
2048
+ * @example
2049
+ * ```typescript
2050
+ * // Invoke on default broker (broker finds any provider)
2051
+ * const result = await client.invokeRemote('add', { a: 5, b: 3 });
2052
+ *
2053
+ * // Invoke on specific broker
2054
+ * const result = await client.invokeRemote('analyze', { text: 'hi' }, {
2055
+ * broker: 'internal',
2056
+ * });
2057
+ * ```
2058
+ */
2059
+ async invokeRemote<T = unknown>(
2060
+ toolName: string,
2061
+ params: unknown,
2062
+ options: InvokeRemoteOptions = {}
2063
+ ): Promise<T> {
2064
+ const { state, brokerName } = this.getConnectedBrokerState(options.broker);
2065
+ const timeout = options.timeout ?? this.config.requestTimeout;
2066
+
2067
+ // Generate requestId FIRST, before any async operations.
2068
+ // This allows us to set up the response listener before sending,
2069
+ // eliminating the race condition where fast responses arrive
2070
+ // before the listener exists.
2071
+ const requestId = crypto.randomUUID();
2072
+
2073
+ // Set up result listener BEFORE sending the request.
2074
+ // When the broker sends kadi.ability.response, handleAbilityResponse()
2075
+ // will find this listener and resolve/reject the promise.
2076
+ const resultPromise = new Promise<T>((resolve, reject) => {
2077
+ const timeoutHandle = setTimeout(() => {
2078
+ state.pendingInvocations.delete(requestId);
2079
+ reject(
2080
+ new KadiError(`Tool invocation "${toolName}" timed out waiting for result`, 'BROKER_TIMEOUT', {
2081
+ broker: brokerName,
2082
+ toolName,
2083
+ requestId,
2084
+ timeout,
2085
+ })
2086
+ );
2087
+ }, timeout);
2088
+
2089
+ state.pendingInvocations.set(requestId, {
2090
+ resolve: (result: unknown) => resolve(result as T),
2091
+ reject,
2092
+ timeout: timeoutHandle,
2093
+ toolName,
2094
+ sentAt: new Date(),
2095
+ });
2096
+ });
2097
+
2098
+ // Helper to clean up the pending invocation on failure
2099
+ const cleanupPendingInvocation = () => {
2100
+ const pending = state.pendingInvocations.get(requestId);
2101
+ if (pending) {
2102
+ clearTimeout(pending.timeout);
2103
+ state.pendingInvocations.delete(requestId);
2104
+ }
2105
+ };
2106
+
2107
+ // NOW send the request (listener already exists, no race possible)
2108
+ try {
2109
+ const pendingResult = await this.sendRequest<{ status: string; requestId: string }>(
2110
+ state,
2111
+ {
2112
+ jsonrpc: '2.0',
2113
+ id: ++this.nextRequestId,
2114
+ method: 'kadi.ability.request',
2115
+ params: { toolName, toolInput: params, requestId },
2116
+ }
2117
+ );
2118
+
2119
+ // Validate the broker accepted the request
2120
+ if (pendingResult.status !== 'pending') {
2121
+ cleanupPendingInvocation();
2122
+ throw new KadiError(
2123
+ 'Unexpected response from broker: expected pending acknowledgment',
2124
+ 'BROKER_ERROR',
2125
+ { broker: brokerName, toolName, response: pendingResult }
2126
+ );
2127
+ }
2128
+ } catch (error) {
2129
+ cleanupPendingInvocation();
2130
+ throw error;
2131
+ }
2132
+
2133
+ return resultPromise;
2134
+ }
2135
+
2136
+ // ─────────────────────────────────────────────────────────────
2137
+ // SERVE MODE
2138
+ // ─────────────────────────────────────────────────────────────
2139
+
2140
+ /**
2141
+ * Start serving this agent.
2142
+ *
2143
+ * @param mode - 'stdio' for JSON-RPC over stdin/stdout, 'broker' for broker connection
2144
+ *
2145
+ * @example
2146
+ * ```typescript
2147
+ * // Serve as a stdio server (for loadStdio)
2148
+ * await client.serve('stdio');
2149
+ *
2150
+ * // Serve via broker (connects and waits)
2151
+ * await client.serve('broker');
2152
+ * ```
2153
+ */
2154
+ async serve(mode: 'stdio' | 'broker'): Promise<void> {
2155
+ if (mode === 'stdio') {
2156
+ await this.serveStdio();
2157
+ } else {
2158
+ await this.serveBroker();
2159
+ }
2160
+ }
2161
+
2162
+ /**
2163
+ * Serve as a stdio server (JSON-RPC over stdin/stdout).
2164
+ *
2165
+ * Steps:
2166
+ * 1. Redirect console.log to stderr (keeps stdout clean for JSON-RPC)
2167
+ * 2. Set up binary-safe buffer for message parsing
2168
+ * 3. Set up signal handlers for graceful shutdown
2169
+ * 4. Process incoming messages
2170
+ */
2171
+ private async serveStdio(): Promise<void> {
2172
+ // Mark that we're serving stdio (for emit() to know it can write to stdout)
2173
+ this.isServingStdio = true;
2174
+
2175
+ // Step 1: Redirect console.log to stderr (keeps stdout clean for JSON-RPC)
2176
+ // Without this, any console.log() in tool handlers corrupts the protocol
2177
+ const originalLog = console.log;
2178
+ console.log = console.error;
2179
+
2180
+ // Step 2: Binary-safe buffer and constants
2181
+ let buffer = Buffer.alloc(0);
2182
+ const HEADER_END = '\r\n\r\n';
2183
+ const HEADER_END_LENGTH = 4;
2184
+ const CONTENT_LENGTH_PATTERN = /Content-Length:\s*(\d+)/;
2185
+
2186
+ /**
2187
+ * Try to read a single complete message from the buffer.
2188
+ */
2189
+ const tryReadSingleMessage = (): JsonRpcRequest | null => {
2190
+ const headerEndIndex = buffer.indexOf(HEADER_END);
2191
+ if (headerEndIndex === -1) {
2192
+ return null;
2193
+ }
2194
+
2195
+ const header = buffer.slice(0, headerEndIndex).toString('utf8');
2196
+ const match = header.match(CONTENT_LENGTH_PATTERN);
2197
+ if (!match?.[1]) {
2198
+ // Malformed header - log and clear buffer (don't silently recover)
2199
+ console.error('[KADI Stdio Server] Malformed header - missing Content-Length');
2200
+ buffer = Buffer.alloc(0);
2201
+ return null;
2202
+ }
2203
+
2204
+ const contentLength = parseInt(match[1], 10);
2205
+ const contentStart = headerEndIndex + HEADER_END_LENGTH;
2206
+ const contentEnd = contentStart + contentLength;
2207
+
2208
+ if (buffer.length < contentEnd) {
2209
+ return null;
2210
+ }
2211
+
2212
+ const content = buffer.slice(contentStart, contentEnd).toString('utf8');
2213
+ buffer = buffer.slice(contentEnd);
2214
+
2215
+ try {
2216
+ return JSON.parse(content) as JsonRpcRequest;
2217
+ } catch {
2218
+ console.error('[KADI Stdio Server] Invalid JSON in message');
2219
+ return null;
2220
+ }
2221
+ };
2222
+
2223
+ // Step 3: Graceful shutdown handler
2224
+ // CRITICAL: Disconnects loaded abilities to prevent orphan processes
2225
+ const cleanup = async () => {
2226
+ console.error('[KADI Stdio Server] Shutting down...');
2227
+ await this.disconnect();
2228
+ console.log = originalLog;
2229
+ process.exit(0);
2230
+ };
2231
+
2232
+ process.once('SIGTERM', cleanup);
2233
+ process.once('SIGINT', cleanup);
2234
+
2235
+ // Step 4: Process incoming messages
2236
+ process.stdin.on('data', (chunk: Buffer) => {
2237
+ buffer = Buffer.concat([buffer, chunk]);
2238
+
2239
+ let message: JsonRpcRequest | null;
2240
+ while ((message = tryReadSingleMessage()) !== null) {
2241
+ // Handle message asynchronously but don't await (allows concurrent processing)
2242
+ this.handleStdioMessage(message, cleanup).catch((err) => {
2243
+ console.error('[KADI Stdio Server] Error handling message:', err);
2244
+ });
2245
+ }
2246
+ });
2247
+
2248
+ // Keep process alive indefinitely - shutdown happens via signal handlers
2249
+ await new Promise(() => {});
2250
+ }
2251
+
2252
+ /**
2253
+ * Handle a stdio message.
2254
+ */
2255
+ private async handleStdioMessage(
2256
+ message: JsonRpcRequest,
2257
+ cleanup: () => Promise<void>
2258
+ ): Promise<void> {
2259
+ let result: unknown;
2260
+ let error: { code: number; message: string } | undefined;
2261
+
2262
+ try {
2263
+ if (message.method === 'readAgentJson') {
2264
+ result = this.readAgentJson();
2265
+ } else if (message.method === 'invoke') {
2266
+ const params = message.params as { toolName: string; toolInput: unknown };
2267
+ result = await this.executeToolHandler(params.toolName, params.toolInput);
2268
+ } else if (message.method === 'shutdown') {
2269
+ // Graceful shutdown - send response first, then cleanup
2270
+ this.sendStdioResponse(message.id, { ok: true });
2271
+ await cleanup();
2272
+ return;
2273
+ } else {
2274
+ error = { code: -32601, message: `Method not found: ${message.method}` };
2275
+ }
2276
+ } catch (e) {
2277
+ error = { code: -32000, message: e instanceof Error ? e.message : String(e) };
2278
+ }
2279
+
2280
+ if (error) {
2281
+ this.sendStdioError(message.id, error);
2282
+ } else {
2283
+ this.sendStdioResponse(message.id, result);
2284
+ }
2285
+ }
2286
+
2287
+ /**
2288
+ * Send a response via stdio.
2289
+ */
2290
+ private sendStdioResponse(id: string | number, result: unknown): void {
2291
+ const response: JsonRpcResponse = {
2292
+ jsonrpc: '2.0',
2293
+ id,
2294
+ result,
2295
+ };
2296
+ const json = JSON.stringify(response);
2297
+ process.stdout.write(`Content-Length: ${Buffer.byteLength(json)}\r\n\r\n${json}`);
2298
+ }
2299
+
2300
+ /**
2301
+ * Send an error response via stdio.
2302
+ */
2303
+ private sendStdioError(id: string | number, error: { code: number; message: string }): void {
2304
+ const response: JsonRpcResponse = {
2305
+ jsonrpc: '2.0',
2306
+ id,
2307
+ error,
2308
+ };
2309
+ const json = JSON.stringify(response);
2310
+ process.stdout.write(`Content-Length: ${Buffer.byteLength(json)}\r\n\r\n${json}`);
2311
+ }
2312
+
2313
+ /**
2314
+ * Serve via broker (connect and wait for requests).
2315
+ *
2316
+ * Connects to configured brokers and waits for incoming tool requests.
2317
+ * Sets up graceful shutdown to disconnect loaded abilities.
2318
+ */
2319
+ private async serveBroker(): Promise<void> {
2320
+ await this.connect();
2321
+
2322
+ // Graceful shutdown handler
2323
+ // CRITICAL: Disconnects loaded abilities to prevent orphan processes
2324
+ const cleanup = async () => {
2325
+ console.error('[KADI Broker Server] Shutting down...');
2326
+ await this.disconnect();
2327
+ process.exit(0);
2328
+ };
2329
+
2330
+ process.once('SIGTERM', cleanup);
2331
+ process.once('SIGINT', cleanup);
2332
+
2333
+ // Keep process alive indefinitely - shutdown happens via signal handlers
2334
+ await new Promise(() => {});
2335
+ }
2336
+
2337
+ // ─────────────────────────────────────────────────────────────
2338
+ // UTILITY
2339
+ // ─────────────────────────────────────────────────────────────
2340
+
2341
+ /**
2342
+ * Get agent information (for readAgentJson protocol).
2343
+ */
2344
+ readAgentJson(): { name: string; version: string; tools: ToolDefinition[] } {
2345
+ return {
2346
+ name: this.config.name,
2347
+ version: this.config.version,
2348
+ tools: this.getToolDefinitions(),
2349
+ };
2350
+ }
2351
+
2352
+ /**
2353
+ * Get broker connection state (for broker transport).
2354
+ */
2355
+ getBrokerState(brokerName: string): BrokerState | undefined {
2356
+ return this.brokers.get(brokerName);
2357
+ }
2358
+
2359
+ /**
2360
+ * Check if connected to a specific broker or any broker.
2361
+ *
2362
+ * @param brokerName - Optional broker name to check. If not specified, checks any.
2363
+ */
2364
+ isConnected(brokerName?: string): boolean {
2365
+ if (brokerName) {
2366
+ const state = this.brokers.get(brokerName);
2367
+ return state?.status === 'connected';
2368
+ }
2369
+ for (const [, state] of this.brokers) {
2370
+ if (state.status === 'connected') {
2371
+ return true;
2372
+ }
2373
+ }
2374
+ return false;
2375
+ }
2376
+
2377
+ /**
2378
+ * Get list of connected broker names.
2379
+ */
2380
+ getConnectedBrokers(): string[] {
2381
+ return Array.from(this.brokers.entries())
2382
+ .filter(([, state]) => state.status === 'connected')
2383
+ .map(([name]) => name);
2384
+ }
2385
+ }