@kadi.build/core 0.4.0 → 0.5.0

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