@kadi.build/core 0.0.1-alpha.9 → 0.1.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.
Files changed (126) hide show
  1. package/README.md +362 -1305
  2. package/dist/client.d.ts +573 -0
  3. package/dist/client.d.ts.map +1 -0
  4. package/dist/client.js +1673 -0
  5. package/dist/client.js.map +1 -0
  6. package/dist/errors.d.ts +107 -0
  7. package/dist/errors.d.ts.map +1 -0
  8. package/dist/errors.js +147 -0
  9. package/dist/errors.js.map +1 -0
  10. package/dist/index.d.ts +37 -14
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +40 -23
  13. package/dist/index.js.map +1 -1
  14. package/dist/lockfile.d.ts +190 -0
  15. package/dist/lockfile.d.ts.map +1 -0
  16. package/dist/lockfile.js +373 -0
  17. package/dist/lockfile.js.map +1 -0
  18. package/dist/transports/broker.d.ts +75 -0
  19. package/dist/transports/broker.d.ts.map +1 -0
  20. package/dist/transports/broker.js +383 -0
  21. package/dist/transports/broker.js.map +1 -0
  22. package/dist/transports/native.d.ts +39 -0
  23. package/dist/transports/native.d.ts.map +1 -0
  24. package/dist/transports/native.js +189 -0
  25. package/dist/transports/native.js.map +1 -0
  26. package/dist/transports/stdio.d.ts +46 -0
  27. package/dist/transports/stdio.d.ts.map +1 -0
  28. package/dist/transports/stdio.js +460 -0
  29. package/dist/transports/stdio.js.map +1 -0
  30. package/dist/types.d.ts +664 -0
  31. package/dist/types.d.ts.map +1 -0
  32. package/dist/types.js +16 -0
  33. package/dist/types.js.map +1 -0
  34. package/dist/zod.d.ts +34 -0
  35. package/dist/zod.d.ts.map +1 -0
  36. package/dist/zod.js +60 -0
  37. package/dist/zod.js.map +1 -0
  38. package/package.json +13 -28
  39. package/dist/KadiClient.d.ts +0 -470
  40. package/dist/KadiClient.d.ts.map +0 -1
  41. package/dist/KadiClient.js +0 -1572
  42. package/dist/KadiClient.js.map +0 -1
  43. package/dist/errors/error-codes.d.ts +0 -985
  44. package/dist/errors/error-codes.d.ts.map +0 -1
  45. package/dist/errors/error-codes.js +0 -638
  46. package/dist/errors/error-codes.js.map +0 -1
  47. package/dist/loadAbility.d.ts +0 -105
  48. package/dist/loadAbility.d.ts.map +0 -1
  49. package/dist/loadAbility.js +0 -370
  50. package/dist/loadAbility.js.map +0 -1
  51. package/dist/messages/BrokerMessages.d.ts +0 -84
  52. package/dist/messages/BrokerMessages.d.ts.map +0 -1
  53. package/dist/messages/BrokerMessages.js +0 -125
  54. package/dist/messages/BrokerMessages.js.map +0 -1
  55. package/dist/messages/MessageBuilder.d.ts +0 -83
  56. package/dist/messages/MessageBuilder.d.ts.map +0 -1
  57. package/dist/messages/MessageBuilder.js +0 -144
  58. package/dist/messages/MessageBuilder.js.map +0 -1
  59. package/dist/schemas/events.schemas.d.ts +0 -177
  60. package/dist/schemas/events.schemas.d.ts.map +0 -1
  61. package/dist/schemas/events.schemas.js +0 -265
  62. package/dist/schemas/events.schemas.js.map +0 -1
  63. package/dist/schemas/index.d.ts +0 -3
  64. package/dist/schemas/index.d.ts.map +0 -1
  65. package/dist/schemas/index.js +0 -4
  66. package/dist/schemas/index.js.map +0 -1
  67. package/dist/schemas/kadi.schemas.d.ts +0 -70
  68. package/dist/schemas/kadi.schemas.d.ts.map +0 -1
  69. package/dist/schemas/kadi.schemas.js +0 -120
  70. package/dist/schemas/kadi.schemas.js.map +0 -1
  71. package/dist/transports/BrokerTransport.d.ts +0 -102
  72. package/dist/transports/BrokerTransport.d.ts.map +0 -1
  73. package/dist/transports/BrokerTransport.js +0 -177
  74. package/dist/transports/BrokerTransport.js.map +0 -1
  75. package/dist/transports/NativeTransport.d.ts +0 -82
  76. package/dist/transports/NativeTransport.d.ts.map +0 -1
  77. package/dist/transports/NativeTransport.js +0 -263
  78. package/dist/transports/NativeTransport.js.map +0 -1
  79. package/dist/transports/StdioTransport.d.ts +0 -112
  80. package/dist/transports/StdioTransport.d.ts.map +0 -1
  81. package/dist/transports/StdioTransport.js +0 -450
  82. package/dist/transports/StdioTransport.js.map +0 -1
  83. package/dist/transports/Transport.d.ts +0 -93
  84. package/dist/transports/Transport.d.ts.map +0 -1
  85. package/dist/transports/Transport.js +0 -13
  86. package/dist/transports/Transport.js.map +0 -1
  87. package/dist/types/broker.d.ts +0 -31
  88. package/dist/types/broker.d.ts.map +0 -1
  89. package/dist/types/broker.js +0 -6
  90. package/dist/types/broker.js.map +0 -1
  91. package/dist/types/core.d.ts +0 -139
  92. package/dist/types/core.d.ts.map +0 -1
  93. package/dist/types/core.js +0 -26
  94. package/dist/types/core.js.map +0 -1
  95. package/dist/types/events.d.ts +0 -186
  96. package/dist/types/events.d.ts.map +0 -1
  97. package/dist/types/events.js +0 -16
  98. package/dist/types/events.js.map +0 -1
  99. package/dist/types/index.d.ts +0 -9
  100. package/dist/types/index.d.ts.map +0 -1
  101. package/dist/types/index.js +0 -13
  102. package/dist/types/index.js.map +0 -1
  103. package/dist/types/protocol.d.ts +0 -160
  104. package/dist/types/protocol.d.ts.map +0 -1
  105. package/dist/types/protocol.js +0 -5
  106. package/dist/types/protocol.js.map +0 -1
  107. package/dist/utils/agentUtils.d.ts +0 -187
  108. package/dist/utils/agentUtils.d.ts.map +0 -1
  109. package/dist/utils/agentUtils.js +0 -185
  110. package/dist/utils/agentUtils.js.map +0 -1
  111. package/dist/utils/commandUtils.d.ts +0 -45
  112. package/dist/utils/commandUtils.d.ts.map +0 -1
  113. package/dist/utils/commandUtils.js +0 -145
  114. package/dist/utils/commandUtils.js.map +0 -1
  115. package/dist/utils/configUtils.d.ts +0 -55
  116. package/dist/utils/configUtils.d.ts.map +0 -1
  117. package/dist/utils/configUtils.js +0 -100
  118. package/dist/utils/configUtils.js.map +0 -1
  119. package/dist/utils/logger.d.ts +0 -59
  120. package/dist/utils/logger.d.ts.map +0 -1
  121. package/dist/utils/logger.js +0 -122
  122. package/dist/utils/logger.js.map +0 -1
  123. package/dist/utils/pathUtils.d.ts +0 -48
  124. package/dist/utils/pathUtils.d.ts.map +0 -1
  125. package/dist/utils/pathUtils.js +0 -128
  126. package/dist/utils/pathUtils.js.map +0 -1
package/dist/client.js ADDED
@@ -0,0 +1,1673 @@
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
+ import { WebSocket } from 'ws';
18
+ import * as crypto from 'crypto';
19
+ import { KadiError } from './errors.js';
20
+ import { zodToJsonSchema, isZodSchema } from './zod.js';
21
+ import { resolveAbilityPath, resolveAbilityScript } from './lockfile.js';
22
+ import { loadNativeTransport } from './transports/native.js';
23
+ import { loadStdioTransport } from './transports/stdio.js';
24
+ import { loadBrokerTransport } from './transports/broker.js';
25
+ // ═══════════════════════════════════════════════════════════════
26
+ // CONSTANTS
27
+ // ═══════════════════════════════════════════════════════════════
28
+ const DEFAULT_HEARTBEAT_INTERVAL = 25000; // 25 seconds
29
+ const DEFAULT_REQUEST_TIMEOUT = 30000; // 30 seconds
30
+ const DEFAULT_AUTO_RECONNECT = true;
31
+ const DEFAULT_MAX_RECONNECT_DELAY = 30000; // 30 seconds cap
32
+ const RECONNECT_BASE_DELAY = 1000; // 1 second initial delay
33
+ // ═══════════════════════════════════════════════════════════════
34
+ // MCP CONTENT DETECTION
35
+ // ═══════════════════════════════════════════════════════════════
36
+ /**
37
+ * Check if a value is a valid MCP ContentBlock.
38
+ *
39
+ * MCP supports these content types:
40
+ * - TextContent: { type: 'text', text: string }
41
+ * - ImageContent: { type: 'image', data: string, mimeType: string }
42
+ * - AudioContent: { type: 'audio', data: string, mimeType: string }
43
+ * - EmbeddedResource: { type: 'resource', resource: object }
44
+ * - ResourceLink: { type: 'resource_link', uri: string }
45
+ */
46
+ function isValidMcpContentBlock(item) {
47
+ if (typeof item !== 'object' || item === null)
48
+ return false;
49
+ const block = item;
50
+ switch (block.type) {
51
+ case 'text':
52
+ return typeof block.text === 'string';
53
+ case 'image':
54
+ case 'audio':
55
+ return typeof block.data === 'string' && typeof block.mimeType === 'string';
56
+ case 'resource':
57
+ return typeof block.resource === 'object' && block.resource !== null;
58
+ case 'resource_link':
59
+ return typeof block.uri === 'string';
60
+ default:
61
+ return false;
62
+ }
63
+ }
64
+ /**
65
+ * Check if a result is already in MCP CallToolResult format.
66
+ *
67
+ * This allows tool handlers to return MCP-shaped content (like images)
68
+ * directly, without being double-wrapped.
69
+ *
70
+ * A valid CallToolResult has at least one of:
71
+ * - content: array of ContentBlock (with at least one valid block)
72
+ * - structuredContent: object (JSON data)
73
+ *
74
+ * Optional fields (not used for detection):
75
+ * - isError?: boolean
76
+ *
77
+ * @example
78
+ * // MCP-shaped with content (passes through unchanged):
79
+ * { content: [{ type: 'image', data: 'base64...', mimeType: 'image/png' }] }
80
+ *
81
+ * // MCP-shaped with structuredContent (passes through unchanged):
82
+ * { structuredContent: { models: ['gpt-4', 'claude-3'] } }
83
+ *
84
+ * // NOT MCP-shaped (gets wrapped as text):
85
+ * { models: ['gpt-4', 'claude-3'] }
86
+ */
87
+ function isMcpCallToolResult(result) {
88
+ if (typeof result !== 'object' || result === null)
89
+ return false;
90
+ const obj = result;
91
+ // Check for valid content array (non-empty, all valid blocks)
92
+ const hasValidContent = Array.isArray(obj.content) &&
93
+ obj.content.length > 0 &&
94
+ obj.content.every(isValidMcpContentBlock);
95
+ // Check for structuredContent (any non-null object)
96
+ const hasStructuredContent = typeof obj.structuredContent === 'object' && obj.structuredContent !== null;
97
+ // Must have either content OR structuredContent (or both)
98
+ return hasValidContent || hasStructuredContent;
99
+ }
100
+ // ═══════════════════════════════════════════════════════════════
101
+ // KADCLIENT CLASS
102
+ // ═══════════════════════════════════════════════════════════════
103
+ /**
104
+ * The main client for building KADI agents.
105
+ *
106
+ * @example
107
+ * ```typescript
108
+ * // Create a client with named brokers
109
+ * const client = new KadiClient({
110
+ * name: 'my-agent',
111
+ * brokers: {
112
+ * production: 'ws://broker-prod:8080',
113
+ * internal: 'ws://broker-internal:8080',
114
+ * },
115
+ * defaultBroker: 'production',
116
+ * });
117
+ *
118
+ * // Register a tool
119
+ * client.registerTool({
120
+ * name: 'add',
121
+ * description: 'Add two numbers',
122
+ * input: z.object({ a: z.number(), b: z.number() }),
123
+ * }, async ({ a, b }) => ({ result: a + b }));
124
+ *
125
+ * // Connect to brokers
126
+ * await client.connect();
127
+ *
128
+ * // Load and use abilities
129
+ * const calc = await client.loadNative('calculator');
130
+ * const result = await calc.invoke('multiply', { x: 5, y: 3 });
131
+ * ```
132
+ */
133
+ export class KadiClient {
134
+ /** Resolved configuration with defaults applied */
135
+ config;
136
+ /** Registered tools (local tools this agent provides) */
137
+ tools = new Map();
138
+ /** Broker connections by name */
139
+ brokers = new Map();
140
+ /** Counter for generating unique request IDs */
141
+ nextRequestId = 0;
142
+ /** Event handler callback (set by native transport) */
143
+ eventHandler = null;
144
+ /** Whether we're serving via stdio (set by serve('stdio')) */
145
+ isServingStdio = false;
146
+ // ─────────────────────────────────────────────────────────────
147
+ // CONSTRUCTOR
148
+ // ─────────────────────────────────────────────────────────────
149
+ constructor(config) {
150
+ // Validate required fields
151
+ if (!config.name || typeof config.name !== 'string') {
152
+ throw new KadiError('Client name is required', 'INVALID_CONFIG', {
153
+ hint: 'Provide a name for your agent: new KadiClient({ name: "my-agent" })',
154
+ });
155
+ }
156
+ // Resolve configuration with defaults
157
+ // Auto-select first broker as default if not specified (matches Python behavior)
158
+ const brokers = config.brokers ?? {};
159
+ const firstBrokerName = Object.keys(brokers)[0];
160
+ this.config = {
161
+ name: config.name,
162
+ version: config.version ?? '1.0.0',
163
+ description: config.description ?? '',
164
+ brokers,
165
+ defaultBroker: config.defaultBroker ?? firstBrokerName,
166
+ networks: config.networks ?? ['global'],
167
+ heartbeatInterval: config.heartbeatInterval ?? DEFAULT_HEARTBEAT_INTERVAL,
168
+ requestTimeout: config.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT,
169
+ autoReconnect: config.autoReconnect ?? DEFAULT_AUTO_RECONNECT,
170
+ maxReconnectDelay: config.maxReconnectDelay ?? DEFAULT_MAX_RECONNECT_DELAY,
171
+ };
172
+ // Validate defaultBroker if specified
173
+ if (this.config.defaultBroker && !this.config.brokers[this.config.defaultBroker]) {
174
+ throw new KadiError(`Default broker "${this.config.defaultBroker}" not found in brokers config`, 'INVALID_CONFIG', {
175
+ defaultBroker: this.config.defaultBroker,
176
+ available: Object.keys(this.config.brokers),
177
+ hint: 'defaultBroker must be a key in the brokers map',
178
+ });
179
+ }
180
+ }
181
+ // ─────────────────────────────────────────────────────────────
182
+ // CONNECTION MANAGEMENT
183
+ // ─────────────────────────────────────────────────────────────
184
+ /**
185
+ * Connect to all configured brokers.
186
+ * Call this after registering tools.
187
+ *
188
+ * @example
189
+ * ```typescript
190
+ * await client.connect(); // Connects to all brokers
191
+ * await client.connect('production'); // Connect to specific broker
192
+ * ```
193
+ */
194
+ async connect(brokerName) {
195
+ // If specific broker requested, connect to just that one
196
+ if (brokerName) {
197
+ await this.connectToBroker(brokerName);
198
+ return;
199
+ }
200
+ // Connect to all configured brokers
201
+ const brokerNames = Object.keys(this.config.brokers);
202
+ if (brokerNames.length === 0) {
203
+ // No brokers configured - that's okay for local-only usage
204
+ return;
205
+ }
206
+ // Connect to all brokers in parallel
207
+ await Promise.all(brokerNames.map((name) => this.connectToBroker(name)));
208
+ }
209
+ /**
210
+ * Connect to a specific broker by name.
211
+ *
212
+ * Protocol:
213
+ * 1. Generate Ed25519 keypair
214
+ * 2. Open WebSocket connection
215
+ * 3. Send kadi.session.hello
216
+ * 4. Receive nonce from broker
217
+ * 5. Send kadi.session.authenticate with signed nonce
218
+ * 6. Receive agentId from broker
219
+ * 7. Register tools with broker
220
+ * 8. Start heartbeat
221
+ */
222
+ async connectToBroker(brokerName) {
223
+ const url = this.config.brokers[brokerName];
224
+ if (!url) {
225
+ throw new KadiError(`Broker "${brokerName}" not found in configuration`, 'UNKNOWN_BROKER', {
226
+ broker: brokerName,
227
+ available: Object.keys(this.config.brokers),
228
+ hint: 'Check your brokers configuration',
229
+ });
230
+ }
231
+ // Check if already connected
232
+ if (this.brokers.has(brokerName)) {
233
+ const existing = this.brokers.get(brokerName);
234
+ if (existing.status === 'connected' || existing.status === 'connecting') {
235
+ return;
236
+ }
237
+ }
238
+ // Generate Ed25519 keypair for authentication
239
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
240
+ // Create broker state
241
+ const state = {
242
+ name: brokerName,
243
+ url,
244
+ ws: null,
245
+ agentId: '',
246
+ publicKey: publicKey.export({ type: 'spki', format: 'der' }),
247
+ privateKey: privateKey.export({ type: 'pkcs8', format: 'der' }),
248
+ heartbeatTimer: null,
249
+ pendingRequests: new Map(),
250
+ pendingInvocations: new Map(),
251
+ status: 'connecting',
252
+ // Reconnection state
253
+ intentionalDisconnect: false,
254
+ reconnectAttempts: 0,
255
+ reconnectTimer: null,
256
+ // Event subscriptions (pub/sub)
257
+ eventHandlers: new Map(),
258
+ subscribedPatterns: new Set(),
259
+ };
260
+ this.brokers.set(brokerName, state);
261
+ // Open WebSocket connection
262
+ await this.openWebSocket(state);
263
+ // Perform handshake
264
+ await this.performHandshake(state);
265
+ // Register with broker (transitions to "ready" state)
266
+ await this.registerWithBroker(state);
267
+ // Start heartbeat
268
+ state.heartbeatTimer = setInterval(() => {
269
+ this.sendHeartbeat(state);
270
+ }, this.config.heartbeatInterval);
271
+ state.status = 'connected';
272
+ }
273
+ // ─────────────────────────────────────────────────────────────
274
+ // MESSAGE BUILDERS
275
+ // ─────────────────────────────────────────────────────────────
276
+ buildHelloMessage() {
277
+ return {
278
+ jsonrpc: '2.0',
279
+ id: this.nextRequestId++,
280
+ method: 'kadi.session.hello',
281
+ params: { role: 'agent' },
282
+ };
283
+ }
284
+ buildAuthMessage(state, nonce) {
285
+ const privateKey = crypto.createPrivateKey({
286
+ key: state.privateKey,
287
+ format: 'der',
288
+ type: 'pkcs8',
289
+ });
290
+ const signature = crypto.sign(null, Buffer.from(nonce, 'utf8'), privateKey);
291
+ return {
292
+ jsonrpc: '2.0',
293
+ id: this.nextRequestId++,
294
+ method: 'kadi.session.authenticate',
295
+ params: {
296
+ publicKey: state.publicKey.toString('base64'),
297
+ signature: signature.toString('base64'),
298
+ nonce,
299
+ },
300
+ };
301
+ }
302
+ buildRegisterMessage(tools, networks) {
303
+ return {
304
+ jsonrpc: '2.0',
305
+ id: this.nextRequestId++,
306
+ method: 'kadi.agent.register',
307
+ params: {
308
+ tools,
309
+ networks,
310
+ displayName: this.config.name,
311
+ },
312
+ };
313
+ }
314
+ buildHeartbeatMessage() {
315
+ return {
316
+ jsonrpc: '2.0',
317
+ id: this.nextRequestId++,
318
+ method: 'kadi.session.heartbeat',
319
+ params: { timestamp: Date.now() },
320
+ };
321
+ }
322
+ // ─────────────────────────────────────────────────────────────
323
+ // CONNECTION HELPERS
324
+ // ─────────────────────────────────────────────────────────────
325
+ /**
326
+ * Open WebSocket connection to broker.
327
+ */
328
+ openWebSocket(state) {
329
+ return new Promise((resolve, reject) => {
330
+ const ws = new WebSocket(state.url);
331
+ state.ws = ws;
332
+ const timeout = setTimeout(() => {
333
+ ws.close();
334
+ reject(new KadiError(`Timeout connecting to broker "${state.name}"`, 'BROKER_TIMEOUT', {
335
+ broker: state.name,
336
+ url: state.url,
337
+ }));
338
+ }, this.config.requestTimeout);
339
+ ws.on('open', () => {
340
+ clearTimeout(timeout);
341
+ resolve();
342
+ });
343
+ ws.on('error', (error) => {
344
+ clearTimeout(timeout);
345
+ state.status = 'disconnected';
346
+ reject(new KadiError(`Failed to connect to broker "${state.name}"`, 'CONNECTION_FAILED', {
347
+ broker: state.name,
348
+ url: state.url,
349
+ reason: error.message,
350
+ }));
351
+ });
352
+ ws.on('close', () => {
353
+ this.handleWebSocketClose(state);
354
+ });
355
+ ws.on('message', (data) => this.handleBrokerMessage(state, data));
356
+ });
357
+ }
358
+ /**
359
+ * Perform handshake: hello → authenticate.
360
+ *
361
+ * Protocol:
362
+ * 1. Client sends kadi.session.hello
363
+ * 2. Broker responds with { nonce: "..." }
364
+ * 3. Client signs nonce and sends kadi.session.authenticate
365
+ * 4. Broker responds with { agentId: "..." }
366
+ */
367
+ async performHandshake(state) {
368
+ // Step 1: Send hello, get nonce for challenge-response auth
369
+ // The broker sends a random nonce that we must sign to prove key ownership
370
+ const helloResult = await this.sendRequest(state, this.buildHelloMessage());
371
+ // Validate the response shape - sendRequest<T> is type documentation only,
372
+ // not runtime enforcement. We must validate critical fields ourselves.
373
+ if (!helloResult.nonce) {
374
+ throw new KadiError('Hello response missing nonce', 'AUTHENTICATION_FAILED', {
375
+ broker: state.name,
376
+ hint: 'Broker may be misconfigured or running incompatible version',
377
+ });
378
+ }
379
+ // Step 2: Sign the nonce and authenticate
380
+ // This proves we own the private key without revealing it
381
+ const authResult = await this.sendRequest(state, this.buildAuthMessage(state, helloResult.nonce));
382
+ if (!authResult.agentId) {
383
+ throw new KadiError('Auth response missing agentId', 'AUTHENTICATION_FAILED', {
384
+ broker: state.name,
385
+ });
386
+ }
387
+ state.agentId = authResult.agentId;
388
+ }
389
+ /**
390
+ * Register with broker after authentication.
391
+ *
392
+ * This transitions the session from "authenticated" to "ready" state.
393
+ * Must be called even if no tools are registered - the broker requires
394
+ * the agent to be in "ready" state before it can invoke remote tools.
395
+ */
396
+ async registerWithBroker(state) {
397
+ // Get tools targeted for this specific broker
398
+ const tools = this.getToolDefinitions(state.name);
399
+ // Note: If registration fails, sendRequest will reject with the error.
400
+ // We don't need to check response.error here - errors are already
401
+ // handled via Promise rejection in handleBrokerResponse.
402
+ await this.sendRequest(state, this.buildRegisterMessage(tools, this.config.networks));
403
+ }
404
+ /**
405
+ * Send a JSON-RPC request and wait for the response.
406
+ *
407
+ * Design: Returns the result directly (not the full JSON-RPC response).
408
+ *
409
+ * Why? The JSON-RPC envelope (jsonrpc, id) is transport metadata.
410
+ * Once we've matched the response to the pending request, that metadata
411
+ * has served its purpose. Callers care about the result, not the envelope.
412
+ *
413
+ * Errors are handled via Promise rejection in handleBrokerResponse,
414
+ * so by the time this resolves, we know it's a successful response.
415
+ *
416
+ * IMPORTANT: The type parameter T is NOT validated at runtime.
417
+ * It provides TypeScript type hints but the broker could return any shape.
418
+ * Callers MUST validate critical fields before using them.
419
+ *
420
+ * @template T - Expected type of the result (defaults to unknown for safety)
421
+ * @param state - Broker connection state
422
+ * @param request - JSON-RPC request to send
423
+ * @returns Promise resolving to the typed result
424
+ *
425
+ * @example
426
+ * ```typescript
427
+ * // Caller specifies expected result type
428
+ * const result = await this.sendRequest<{ nonce: string }>(state, helloMessage);
429
+ * // IMPORTANT: Validate before using - the type is not runtime-enforced
430
+ * if (!result.nonce) throw new Error('Missing nonce');
431
+ * console.log(result.nonce);
432
+ * ```
433
+ */
434
+ sendRequest(state, request) {
435
+ // Fail fast if WebSocket is not connected
436
+ // Without this check, the request would sit pending until timeout (30s),
437
+ // giving a misleading BROKER_TIMEOUT error instead of the real problem.
438
+ const ws = state.ws;
439
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
440
+ return Promise.reject(new KadiError('Cannot send request: WebSocket not connected', 'BROKER_NOT_CONNECTED', {
441
+ broker: state.name,
442
+ method: request.method,
443
+ hint: 'Call client.connect() first',
444
+ }));
445
+ }
446
+ return new Promise((resolve, reject) => {
447
+ const timeout = setTimeout(() => {
448
+ state.pendingRequests.delete(request.id);
449
+ reject(new KadiError(`Request timeout: ${request.method}`, 'BROKER_TIMEOUT', {
450
+ broker: state.name,
451
+ method: request.method,
452
+ }));
453
+ }, this.config.requestTimeout);
454
+ // Store the pending request. When handleBrokerResponse receives a response,
455
+ // it will clear the timeout, delete from map, and call resolve/reject.
456
+ state.pendingRequests.set(request.id, {
457
+ resolve: (result) => resolve(result),
458
+ reject,
459
+ timeout,
460
+ method: request.method,
461
+ sentAt: new Date(),
462
+ });
463
+ ws.send(JSON.stringify(request));
464
+ });
465
+ }
466
+ // ─────────────────────────────────────────────────────────────
467
+ // MESSAGE HANDLING
468
+ // ─────────────────────────────────────────────────────────────
469
+ /**
470
+ * Handle incoming messages from broker.
471
+ */
472
+ handleBrokerMessage(state, data) {
473
+ let message;
474
+ try {
475
+ message = JSON.parse(data.toString());
476
+ }
477
+ catch {
478
+ return; // Invalid JSON - ignore
479
+ }
480
+ // Handle responses (has id, has result or error)
481
+ if ('id' in message && ('result' in message || 'error' in message)) {
482
+ this.handleBrokerResponse(state, message);
483
+ return;
484
+ }
485
+ // Handle requests (has id, has method) - tool invocations from broker
486
+ if ('id' in message && 'method' in message) {
487
+ this.handleBrokerRequest(state, message);
488
+ return;
489
+ }
490
+ // Handle notifications (no id, has method)
491
+ if (!('id' in message) && 'method' in message) {
492
+ const notification = message;
493
+ // Route kadi.ability.response notifications to pending invocations
494
+ if (notification.method === 'kadi.ability.response') {
495
+ this.handleAbilityResponse(state, notification);
496
+ }
497
+ // Route kadi.event.delivery notifications to event handlers
498
+ else if (notification.method === 'kadi.event.delivery') {
499
+ this.handleEventDelivery(state, notification);
500
+ }
501
+ }
502
+ }
503
+ /**
504
+ * Handle kadi.ability.response notification.
505
+ *
506
+ * This is the second part of the async tool invocation pattern:
507
+ * 1. Client sends kadi.ability.request → gets { status: 'pending', requestId }
508
+ * 2. Provider executes tool and sends result back to broker
509
+ * 3. Broker sends this notification with the actual result
510
+ *
511
+ * The notification contains:
512
+ * - requestId: matches what we got in step 1
513
+ * - result: the tool's return value (if successful)
514
+ * - error: error message (if failed)
515
+ */
516
+ handleAbilityResponse(state, notification) {
517
+ // The error field can be either a string or an object with code/message.
518
+ // We accept both forms and normalize for consistent error handling.
519
+ const params = notification.params;
520
+ // Validate notification has requestId
521
+ if (!params?.requestId) {
522
+ // Malformed notification - ignore
523
+ return;
524
+ }
525
+ // Find pending invocation
526
+ const pending = state.pendingInvocations.get(params.requestId);
527
+ if (!pending) {
528
+ // No one waiting for this - might be stale or already timed out
529
+ return;
530
+ }
531
+ // Clean up: remove from pending and clear timeout
532
+ clearTimeout(pending.timeout);
533
+ state.pendingInvocations.delete(params.requestId);
534
+ // Resolve or reject based on response content
535
+ if (params.error) {
536
+ // Normalize error - could be string or { code, message } object
537
+ const errorMessage = typeof params.error === 'string' ? params.error : params.error.message;
538
+ const errorCode = typeof params.error === 'object' ? params.error.code : undefined;
539
+ pending.reject(new KadiError(`Tool "${pending.toolName}" failed: ${errorMessage}`, 'TOOL_INVOCATION_FAILED', {
540
+ toolName: pending.toolName,
541
+ requestId: params.requestId,
542
+ broker: state.name,
543
+ errorCode, // Include error code if available
544
+ }));
545
+ }
546
+ else {
547
+ pending.resolve(params.result);
548
+ }
549
+ }
550
+ // ═══════════════════════════════════════════════════════════════
551
+ // BROKER EVENTS (Pub/Sub)
552
+ // ═══════════════════════════════════════════════════════════════
553
+ /**
554
+ * Handle kadi.event.delivery notification from broker.
555
+ *
556
+ * When an event is published to a channel that matches one of our subscribed
557
+ * patterns, the broker sends us this notification with the event data.
558
+ *
559
+ * Flow:
560
+ * 1. Some agent calls publish('user.login', data)
561
+ * 2. Broker routes to all subscribers matching 'user.*' or 'user.#' etc.
562
+ * 3. We receive this notification and dispatch to local handlers
563
+ *
564
+ * The notification params contain:
565
+ * - channel: The exact channel name (e.g., 'user.login')
566
+ * - data: The event payload
567
+ * - networkId: Which network the event was published to
568
+ * - source: Session ID of the publisher
569
+ * - timestamp: When the event was published
570
+ */
571
+ handleEventDelivery(state, notification) {
572
+ const params = notification.params;
573
+ // Validate notification has required fields
574
+ if (!params?.channel || params.networkId === undefined) {
575
+ // Malformed notification - ignore
576
+ return;
577
+ }
578
+ // Create the event object to pass to handlers
579
+ const event = {
580
+ channel: params.channel,
581
+ data: params.data,
582
+ networkId: params.networkId,
583
+ source: params.source ?? 'unknown',
584
+ timestamp: params.timestamp ?? Date.now(),
585
+ };
586
+ // Dispatch to all matching handlers
587
+ // We need to check which patterns match this channel
588
+ for (const [pattern, handlers] of state.eventHandlers) {
589
+ if (this.patternMatchesChannel(pattern, event.channel)) {
590
+ for (const handler of handlers) {
591
+ // Fire-and-forget - don't await, don't let one handler block others
592
+ try {
593
+ const result = handler(event);
594
+ // If handler returns a promise, catch errors but don't block
595
+ if (result instanceof Promise) {
596
+ result.catch((err) => {
597
+ // Log but don't throw - one handler error shouldn't affect others
598
+ console.error(`[KADI] Event handler error for pattern "${pattern}":`, err);
599
+ });
600
+ }
601
+ }
602
+ catch (err) {
603
+ // Sync error in handler
604
+ console.error(`[KADI] Event handler error for pattern "${pattern}":`, err);
605
+ }
606
+ }
607
+ }
608
+ }
609
+ }
610
+ /**
611
+ * Check if a subscription pattern matches an event channel.
612
+ *
613
+ * Pattern matching follows RabbitMQ topic exchange rules:
614
+ * - '*' matches exactly one word (between dots)
615
+ * - '#' matches zero or more words
616
+ * - Literal strings match exactly
617
+ *
618
+ * Examples:
619
+ * - 'user.*' matches 'user.login', 'user.logout', NOT 'user.profile.update'
620
+ * - 'user.#' matches 'user.login', 'user.profile.update', even just 'user'
621
+ * - 'user.login' matches only 'user.login'
622
+ *
623
+ * @param pattern - The subscription pattern (e.g., 'user.*')
624
+ * @param channel - The event channel (e.g., 'user.login')
625
+ */
626
+ patternMatchesChannel(pattern, channel) {
627
+ const patternParts = pattern.split('.');
628
+ const channelParts = channel.split('.');
629
+ return this.matchPatternRecursive(patternParts, 0, channelParts, 0);
630
+ }
631
+ /**
632
+ * Recursive pattern matcher (RabbitMQ topic exchange style).
633
+ *
634
+ * Example: pattern "user.#.status" vs channel "user.profile.settings.status"
635
+ * → # eats "profile.settings" → match!
636
+ */
637
+ matchPatternRecursive(pattern, pi, channel, ci) {
638
+ // Both exhausted = match
639
+ if (pi === pattern.length && ci === channel.length)
640
+ return true;
641
+ // Pattern done but channel has more = no match
642
+ if (pi === pattern.length)
643
+ return false;
644
+ const p = pattern[pi];
645
+ if (p === '#') {
646
+ // '#' matches zero or more words - try zero first, then one-at-a-time
647
+ if (this.matchPatternRecursive(pattern, pi + 1, channel, ci))
648
+ return true;
649
+ if (ci < channel.length && this.matchPatternRecursive(pattern, pi, channel, ci + 1))
650
+ return true;
651
+ return false;
652
+ }
653
+ // Channel exhausted but pattern needs more = no match
654
+ if (ci === channel.length)
655
+ return false;
656
+ if (p === '*') {
657
+ // '*' matches exactly one word
658
+ return this.matchPatternRecursive(pattern, pi + 1, channel, ci + 1);
659
+ }
660
+ // Literal must match exactly
661
+ return p === channel[ci] && this.matchPatternRecursive(pattern, pi + 1, channel, ci + 1);
662
+ }
663
+ /**
664
+ * Subscribe to events matching a pattern.
665
+ *
666
+ * Pattern matching follows RabbitMQ topic exchange rules:
667
+ * - '*' matches exactly one word (between dots)
668
+ * - '#' matches zero or more words
669
+ *
670
+ * @param pattern - Pattern to subscribe to (e.g., 'user.*', 'order.#')
671
+ * @param handler - Function called when matching event is received
672
+ * @param options - Optional: which broker to subscribe through
673
+ *
674
+ * @example
675
+ * ```typescript
676
+ * // Subscribe to all user events
677
+ * client.subscribe('user.*', (event) => {
678
+ * console.log(`User event: ${event.channel}`, event.data);
679
+ * });
680
+ *
681
+ * // Subscribe to all order events at any depth
682
+ * client.subscribe('order.#', (event) => {
683
+ * console.log(`Order event: ${event.channel}`, event.data);
684
+ * });
685
+ * ```
686
+ */
687
+ async subscribe(pattern, handler, options = {}) {
688
+ // Resolve which broker to use
689
+ const brokerName = options.broker ?? this.config.defaultBroker;
690
+ if (!brokerName) {
691
+ throw new KadiError('No broker specified and no defaultBroker configured', 'INVALID_CONFIG', {
692
+ hint: 'Either specify a broker in options or set defaultBroker in config',
693
+ });
694
+ }
695
+ // Get broker state
696
+ const state = this.brokers.get(brokerName);
697
+ if (!state || state.status !== 'connected') {
698
+ throw new KadiError(`Broker "${brokerName}" is not connected`, 'BROKER_NOT_CONNECTED', {
699
+ broker: brokerName,
700
+ status: state?.status ?? 'not found',
701
+ hint: 'Call client.connect() first',
702
+ });
703
+ }
704
+ // Add handler to local tracking
705
+ let handlers = state.eventHandlers.get(pattern);
706
+ if (!handlers) {
707
+ handlers = new Set();
708
+ state.eventHandlers.set(pattern, handlers);
709
+ }
710
+ handlers.add(handler);
711
+ // If this is the first handler for this pattern, subscribe on broker
712
+ if (!state.subscribedPatterns.has(pattern)) {
713
+ await this.sendRequest(state, {
714
+ jsonrpc: '2.0',
715
+ id: this.nextRequestId++,
716
+ method: 'kadi.event.subscribe',
717
+ params: { pattern },
718
+ });
719
+ state.subscribedPatterns.add(pattern);
720
+ }
721
+ }
722
+ /**
723
+ * Unsubscribe from events.
724
+ *
725
+ * Removes the specified handler from the pattern. If no handlers remain
726
+ * for a pattern, the broker subscription is also removed.
727
+ *
728
+ * @param pattern - Pattern to unsubscribe from
729
+ * @param handler - The handler function to remove
730
+ * @param options - Optional: which broker to unsubscribe from
731
+ *
732
+ * @example
733
+ * ```typescript
734
+ * const handler = (event) => console.log(event);
735
+ * client.subscribe('user.*', handler);
736
+ * // ... later
737
+ * client.unsubscribe('user.*', handler);
738
+ * ```
739
+ */
740
+ async unsubscribe(pattern, handler, options = {}) {
741
+ // Resolve which broker to use
742
+ const brokerName = options.broker ?? this.config.defaultBroker;
743
+ if (!brokerName) {
744
+ throw new KadiError('No broker specified and no defaultBroker configured', 'INVALID_CONFIG', {
745
+ hint: 'Either specify a broker in options or set defaultBroker in config',
746
+ });
747
+ }
748
+ // Get broker state
749
+ const state = this.brokers.get(brokerName);
750
+ if (!state) {
751
+ // Broker not found - nothing to unsubscribe
752
+ return;
753
+ }
754
+ // Remove handler from local tracking
755
+ const handlers = state.eventHandlers.get(pattern);
756
+ if (handlers) {
757
+ handlers.delete(handler);
758
+ // If no more handlers for this pattern, clean up
759
+ if (handlers.size === 0) {
760
+ state.eventHandlers.delete(pattern);
761
+ // Unsubscribe from broker if we were subscribed
762
+ if (state.subscribedPatterns.has(pattern) && state.status === 'connected') {
763
+ try {
764
+ await this.sendRequest(state, {
765
+ jsonrpc: '2.0',
766
+ id: this.nextRequestId++,
767
+ method: 'kadi.event.unsubscribe',
768
+ params: { pattern },
769
+ });
770
+ }
771
+ catch {
772
+ // Ignore errors during unsubscribe (broker might be disconnecting)
773
+ }
774
+ state.subscribedPatterns.delete(pattern);
775
+ }
776
+ }
777
+ }
778
+ }
779
+ /**
780
+ * Publish an event to a channel.
781
+ *
782
+ * Events are published to a specific network. All agents subscribed to
783
+ * matching patterns on that network will receive the event.
784
+ *
785
+ * @param channel - Channel/topic to publish to (e.g., 'user.login')
786
+ * @param data - Event payload (any JSON-serializable data)
787
+ * @param options - Optional: network and broker to publish through
788
+ *
789
+ * @example
790
+ * ```typescript
791
+ * // Publish to default network
792
+ * await client.publish('user.login', { userId: '123', timestamp: Date.now() });
793
+ *
794
+ * // Publish to specific network
795
+ * await client.publish('order.created', orderData, { network: 'internal' });
796
+ * ```
797
+ */
798
+ async publish(channel, data, options = {}) {
799
+ // Resolve which broker to use
800
+ const brokerName = options.broker ?? this.config.defaultBroker;
801
+ if (!brokerName) {
802
+ throw new KadiError('No broker specified and no defaultBroker configured', 'INVALID_CONFIG', {
803
+ hint: 'Either specify a broker in options or set defaultBroker in config',
804
+ });
805
+ }
806
+ // Get broker state
807
+ const state = this.brokers.get(brokerName);
808
+ if (!state || state.status !== 'connected') {
809
+ throw new KadiError(`Broker "${brokerName}" is not connected`, 'BROKER_NOT_CONNECTED', {
810
+ broker: brokerName,
811
+ status: state?.status ?? 'not found',
812
+ hint: 'Call client.connect() first',
813
+ });
814
+ }
815
+ // Resolve which network to publish to
816
+ const networkId = options.network ?? this.config.networks[0] ?? 'global';
817
+ // Send publish request to broker
818
+ await this.sendRequest(state, {
819
+ jsonrpc: '2.0',
820
+ id: this.nextRequestId++,
821
+ method: 'kadi.event.publish',
822
+ params: {
823
+ channel,
824
+ data,
825
+ networkId,
826
+ },
827
+ });
828
+ }
829
+ /**
830
+ * Handle incoming request from broker (tool invocation).
831
+ */
832
+ async handleBrokerRequest(state, request) {
833
+ // Handle tool invocation: kadi.ability.request
834
+ if (request.method === 'kadi.ability.request') {
835
+ const params = request.params;
836
+ const callerProtocol = params._meta?.callerProtocol;
837
+ await this.handleInvokeRequest(state, request.id, params.toolName, params.toolInput, callerProtocol);
838
+ }
839
+ }
840
+ /**
841
+ * Handle incoming tool invocation request from broker.
842
+ *
843
+ * When callerProtocol is 'mcp', responses are formatted for MCP clients:
844
+ * - If result is already MCP-shaped (has valid content array), pass through unchanged
845
+ * - Otherwise, wrap as text: { content: [{ type: 'text', text: '...' }], isError: false }
846
+ *
847
+ * This allows tools to return images, audio, or other MCP content types directly,
848
+ * while plain JSON results are automatically wrapped as text.
849
+ *
850
+ * When callerProtocol is 'kadi' or undefined, raw structured data is returned.
851
+ */
852
+ async handleInvokeRequest(state, requestId, toolName, toolInput, callerProtocol) {
853
+ try {
854
+ const result = await this.invoke(toolName, toolInput);
855
+ // Format response based on caller protocol
856
+ let responseResult;
857
+ if (callerProtocol === 'mcp') {
858
+ // MCP clients expect CallToolResult format.
859
+ // If the result is already MCP-shaped, pass through unchanged.
860
+ // Otherwise, wrap as text content.
861
+ responseResult = isMcpCallToolResult(result)
862
+ ? result
863
+ : { content: [{ type: 'text', text: JSON.stringify(result) }], isError: false };
864
+ }
865
+ else {
866
+ // KADI clients receive raw structured data
867
+ responseResult = result;
868
+ }
869
+ const response = {
870
+ jsonrpc: '2.0',
871
+ id: requestId,
872
+ result: responseResult,
873
+ };
874
+ state.ws?.send(JSON.stringify(response));
875
+ }
876
+ catch (error) {
877
+ const errorMessage = error instanceof Error ? error.message : String(error);
878
+ // For MCP clients, wrap errors in CallToolResult format too
879
+ if (callerProtocol === 'mcp') {
880
+ const response = {
881
+ jsonrpc: '2.0',
882
+ id: requestId,
883
+ result: { content: [{ type: 'text', text: errorMessage }], isError: true },
884
+ };
885
+ state.ws?.send(JSON.stringify(response));
886
+ }
887
+ else {
888
+ // KADI clients receive JSON-RPC error
889
+ const response = {
890
+ jsonrpc: '2.0',
891
+ id: requestId,
892
+ error: {
893
+ code: -32000,
894
+ message: errorMessage,
895
+ },
896
+ };
897
+ state.ws?.send(JSON.stringify(response));
898
+ }
899
+ }
900
+ }
901
+ /**
902
+ * Handle response to a pending request.
903
+ */
904
+ handleBrokerResponse(state, response) {
905
+ const pending = state.pendingRequests.get(response.id);
906
+ if (!pending) {
907
+ return; // No one waiting for this response
908
+ }
909
+ // Clean up: remove from pending and clear timeout
910
+ clearTimeout(pending.timeout);
911
+ state.pendingRequests.delete(response.id);
912
+ if (response.error) {
913
+ pending.reject(new KadiError(response.error.message, 'BROKER_ERROR', {
914
+ code: response.error.code,
915
+ broker: state.name,
916
+ }));
917
+ }
918
+ else {
919
+ pending.resolve(response.result);
920
+ }
921
+ }
922
+ /**
923
+ * Send heartbeat to keep connection alive.
924
+ */
925
+ sendHeartbeat(state) {
926
+ if (state.ws?.readyState === WebSocket.OPEN) {
927
+ const heartbeat = this.buildHeartbeatMessage();
928
+ state.ws.send(JSON.stringify(heartbeat));
929
+ }
930
+ }
931
+ /**
932
+ * Cleanup broker connection state.
933
+ * Called on disconnect or connection failure.
934
+ */
935
+ cleanupBroker(state) {
936
+ // Stop heartbeat
937
+ if (state.heartbeatTimer) {
938
+ clearInterval(state.heartbeatTimer);
939
+ state.heartbeatTimer = null;
940
+ }
941
+ // Cancel any pending reconnect attempt
942
+ if (state.reconnectTimer) {
943
+ clearTimeout(state.reconnectTimer);
944
+ state.reconnectTimer = null;
945
+ }
946
+ // Reject pending requests (hello, auth, register, etc.) and clear their timeouts
947
+ for (const [, pending] of state.pendingRequests) {
948
+ clearTimeout(pending.timeout);
949
+ pending.reject(new KadiError('Broker disconnected', 'BROKER_NOT_CONNECTED'));
950
+ }
951
+ state.pendingRequests.clear();
952
+ // Reject pending invocations (invokeRemote waiting for results) and clear their timeouts
953
+ for (const [, pending] of state.pendingInvocations) {
954
+ clearTimeout(pending.timeout);
955
+ pending.reject(new KadiError(`Tool "${pending.toolName}" failed: broker disconnected`, 'BROKER_NOT_CONNECTED', {
956
+ broker: state.name,
957
+ toolName: pending.toolName,
958
+ }));
959
+ }
960
+ state.pendingInvocations.clear();
961
+ }
962
+ // ─────────────────────────────────────────────────────────────
963
+ // RECONNECTION LOGIC
964
+ // ─────────────────────────────────────────────────────────────
965
+ /**
966
+ * Handle WebSocket close event.
967
+ *
968
+ * Decides whether to attempt reconnection based on:
969
+ * - Was the connection ever established? (don't reconnect on initial failure)
970
+ * - autoReconnect config setting
971
+ * - Whether this was an intentional disconnect (user called disconnect())
972
+ */
973
+ handleWebSocketClose(state) {
974
+ // Capture the status before cleanup changes it
975
+ const wasConnected = state.status === 'connected';
976
+ // Clean up connection resources (heartbeat, pending requests)
977
+ this.cleanupBroker(state);
978
+ // If user intentionally disconnected, don't reconnect
979
+ if (state.intentionalDisconnect) {
980
+ state.status = 'disconnected';
981
+ return;
982
+ }
983
+ // If the connection was never established (initial connection failed),
984
+ // don't reconnect - let the error propagate to the caller
985
+ if (!wasConnected && state.reconnectAttempts === 0) {
986
+ state.status = 'disconnected';
987
+ return;
988
+ }
989
+ // If auto-reconnect is disabled, just mark as disconnected
990
+ if (!this.config.autoReconnect) {
991
+ state.status = 'disconnected';
992
+ console.error(`[KADI] Connection to broker "${state.name}" lost. Auto-reconnect is disabled.`);
993
+ return;
994
+ }
995
+ // Schedule reconnection attempt
996
+ this.scheduleReconnect(state);
997
+ }
998
+ /**
999
+ * Calculate reconnection delay using exponential backoff with jitter.
1000
+ *
1001
+ * Formula: min(baseDelay * 2^attempt, maxDelay) ± 20% jitter
1002
+ *
1003
+ * Examples (with maxDelay=30s):
1004
+ * - Attempt 0: ~1s (1000ms ± 200ms)
1005
+ * - Attempt 1: ~2s (2000ms ± 400ms)
1006
+ * - Attempt 2: ~4s (4000ms ± 800ms)
1007
+ * - Attempt 3: ~8s
1008
+ * - Attempt 4: ~16s
1009
+ * - Attempt 5+: ~30s (capped)
1010
+ *
1011
+ * Why jitter? When a broker restarts and 100 agents try to reconnect,
1012
+ * jitter spreads them out so they don't all hit at once (thundering herd).
1013
+ */
1014
+ getReconnectDelay(attempt) {
1015
+ // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s...
1016
+ const exponentialDelay = RECONNECT_BASE_DELAY * Math.pow(2, attempt);
1017
+ // Cap at maximum delay
1018
+ const cappedDelay = Math.min(exponentialDelay, this.config.maxReconnectDelay);
1019
+ // Add jitter: ±20% randomization
1020
+ // (Math.random() * 2 - 1) gives a value between -1 and 1
1021
+ const jitterFactor = 0.2;
1022
+ const jitter = cappedDelay * jitterFactor * (Math.random() * 2 - 1);
1023
+ return Math.round(cappedDelay + jitter);
1024
+ }
1025
+ /**
1026
+ * Schedule a reconnection attempt.
1027
+ *
1028
+ * Logs the attempt number and delay for visibility.
1029
+ *
1030
+ * Note: Reconnection continues indefinitely until successful or
1031
+ * disconnect() is called. There is no max attempt limit - only
1032
+ * the delay is capped at maxReconnectDelay.
1033
+ */
1034
+ scheduleReconnect(state) {
1035
+ state.status = 'reconnecting';
1036
+ state.reconnectAttempts++;
1037
+ const delay = this.getReconnectDelay(state.reconnectAttempts - 1);
1038
+ console.error(`[KADI] Connection to broker "${state.name}" lost. ` +
1039
+ `Reconnecting in ${(delay / 1000).toFixed(1)}s (attempt ${state.reconnectAttempts})...`);
1040
+ state.reconnectTimer = setTimeout(() => {
1041
+ this.attemptReconnect(state);
1042
+ }, delay);
1043
+ }
1044
+ /**
1045
+ * Attempt to reconnect to the broker.
1046
+ *
1047
+ * This reuses the existing keypair (agentId stays the same) and
1048
+ * re-registers tools with the broker.
1049
+ *
1050
+ * On failure, schedules another attempt with increased delay.
1051
+ */
1052
+ async attemptReconnect(state) {
1053
+ state.reconnectTimer = null;
1054
+ try {
1055
+ // Reopen WebSocket connection
1056
+ await this.openWebSocket(state);
1057
+ // Re-authenticate with existing keypair
1058
+ await this.performHandshake(state);
1059
+ // Re-register tools
1060
+ await this.registerWithBroker(state);
1061
+ // Restart heartbeat
1062
+ state.heartbeatTimer = setInterval(() => {
1063
+ this.sendHeartbeat(state);
1064
+ }, this.config.heartbeatInterval);
1065
+ // Success!
1066
+ console.error(`[KADI] Reconnected to broker "${state.name}" after ${state.reconnectAttempts} attempts`);
1067
+ state.reconnectAttempts = 0;
1068
+ state.status = 'connected';
1069
+ }
1070
+ catch (error) {
1071
+ // Log the error and try again
1072
+ const message = error instanceof Error ? error.message : String(error);
1073
+ console.error(`[KADI] Reconnection to broker "${state.name}" failed: ${message}`);
1074
+ // If still not intentionally disconnected, schedule another attempt
1075
+ if (!state.intentionalDisconnect) {
1076
+ this.scheduleReconnect(state);
1077
+ }
1078
+ }
1079
+ }
1080
+ /**
1081
+ * Disconnect from broker(s).
1082
+ *
1083
+ * @param brokerName - If provided, disconnect only from that broker.
1084
+ * If not provided, disconnect from all brokers.
1085
+ */
1086
+ async disconnect(brokerName) {
1087
+ if (brokerName) {
1088
+ // Disconnect from specific broker
1089
+ const state = this.brokers.get(brokerName);
1090
+ if (state) {
1091
+ this.disconnectBroker(state);
1092
+ this.brokers.delete(brokerName);
1093
+ }
1094
+ }
1095
+ else {
1096
+ // Disconnect from all brokers
1097
+ for (const [_name, state] of this.brokers) {
1098
+ this.disconnectBroker(state);
1099
+ }
1100
+ this.brokers.clear();
1101
+ }
1102
+ }
1103
+ /**
1104
+ * Internal helper to disconnect a single broker.
1105
+ *
1106
+ * IMPORTANT: The order of operations matters!
1107
+ * 1. Set intentionalDisconnect BEFORE closing WebSocket
1108
+ * 2. ws.close() triggers handleWebSocketClose(), which checks this flag
1109
+ * 3. If the flag isn't set first, reconnection would be triggered
1110
+ */
1111
+ disconnectBroker(state) {
1112
+ state.intentionalDisconnect = true;
1113
+ state.status = 'disconnecting';
1114
+ this.cleanupBroker(state);
1115
+ state.ws?.close();
1116
+ state.status = 'disconnected';
1117
+ }
1118
+ // ─────────────────────────────────────────────────────────────
1119
+ // EVENTS
1120
+ // ─────────────────────────────────────────────────────────────
1121
+ /**
1122
+ * Set the event handler callback.
1123
+ * Called by native/stdio transport when loading this ability.
1124
+ * @internal
1125
+ */
1126
+ setEventHandler(handler) {
1127
+ this.eventHandler = handler;
1128
+ }
1129
+ /**
1130
+ * Emit an event to the consumer that loaded this ability.
1131
+ *
1132
+ * Events are fire-and-forget notifications - the consumer does not
1133
+ * send a response. Use this for real-time updates, state changes,
1134
+ * or any notification that doesn't require a response.
1135
+ *
1136
+ * @param event - Event name (e.g., 'file.changed', 'user.login')
1137
+ * @param data - Event payload
1138
+ *
1139
+ * @example
1140
+ * ```typescript
1141
+ * // Emit an event when something happens
1142
+ * client.emit('file.changed', { path: '/tmp/foo.txt', action: 'modified' });
1143
+ *
1144
+ * // Emit progress updates
1145
+ * client.emit('job.progress', { percent: 50, message: 'Halfway done' });
1146
+ * ```
1147
+ */
1148
+ emit(event, data) {
1149
+ if (this.eventHandler) {
1150
+ // Native transport: direct callback
1151
+ this.eventHandler(event, data);
1152
+ }
1153
+ else if (this.isServingStdio) {
1154
+ // Stdio transport: write notification to stdout
1155
+ const notification = {
1156
+ jsonrpc: '2.0',
1157
+ method: 'event',
1158
+ params: { name: event, data },
1159
+ };
1160
+ const json = JSON.stringify(notification);
1161
+ process.stdout.write(`Content-Length: ${Buffer.byteLength(json)}\r\n\r\n${json}`);
1162
+ }
1163
+ // If neither condition is met, emit is a no-op (no consumer listening)
1164
+ }
1165
+ // ─────────────────────────────────────────────────────────────
1166
+ // TOOL REGISTRATION
1167
+ // ─────────────────────────────────────────────────────────────
1168
+ /**
1169
+ * Register a tool with this agent.
1170
+ *
1171
+ * @param definition - Tool definition (with Zod schemas)
1172
+ * @param handler - Function to handle invocations
1173
+ * @param options - Registration options (broker targeting)
1174
+ *
1175
+ * @example
1176
+ * ```typescript
1177
+ * client.registerTool({
1178
+ * name: 'add',
1179
+ * description: 'Add two numbers',
1180
+ * input: z.object({ a: z.number(), b: z.number() }),
1181
+ * output: z.object({ result: z.number() }),
1182
+ * }, async ({ a, b }) => ({ result: a + b }));
1183
+ *
1184
+ * // Register only on specific broker
1185
+ * client.registerTool(def, handler, { brokers: ['internal'] });
1186
+ * ```
1187
+ */
1188
+ registerTool(definition, handler, options = {}) {
1189
+ // Check for duplicate
1190
+ if (this.tools.has(definition.name)) {
1191
+ throw new KadiError(`Tool "${definition.name}" is already registered`, 'INVALID_CONFIG', {
1192
+ toolName: definition.name,
1193
+ hint: 'Each tool must have a unique name',
1194
+ });
1195
+ }
1196
+ // Convert Zod schemas to JSON Schema
1197
+ const jsonDefinition = {
1198
+ name: definition.name,
1199
+ description: definition.description,
1200
+ inputSchema: isZodSchema(definition.input) ? zodToJsonSchema(definition.input) : { type: 'object' },
1201
+ outputSchema: definition.output && isZodSchema(definition.output)
1202
+ ? zodToJsonSchema(definition.output)
1203
+ : undefined,
1204
+ };
1205
+ // Store registration
1206
+ const registered = {
1207
+ definition: jsonDefinition,
1208
+ handler: handler,
1209
+ registeredAt: new Date(),
1210
+ targetBrokers: options.brokers ?? [],
1211
+ };
1212
+ this.tools.set(definition.name, registered);
1213
+ }
1214
+ /**
1215
+ */
1216
+ /**
1217
+ * Get tool definitions, optionally filtered for a specific broker.
1218
+ *
1219
+ * @param forBroker - If provided, only return tools targeted for this broker.
1220
+ * Tools with empty targetBrokers are included for all brokers.
1221
+ */
1222
+ getToolDefinitions(forBroker) {
1223
+ return Array.from(this.tools.values())
1224
+ .filter((t) => {
1225
+ // If no broker specified, return all tools (e.g., for readAgentJson)
1226
+ if (!forBroker)
1227
+ return true;
1228
+ // Empty targetBrokers means "register with all brokers"
1229
+ if (t.targetBrokers.length === 0)
1230
+ return true;
1231
+ // Otherwise, only include if this broker is in the target list
1232
+ return t.targetBrokers.includes(forBroker);
1233
+ })
1234
+ .map((t) => t.definition);
1235
+ }
1236
+ /**
1237
+ * Invoke a local tool by name.
1238
+ */
1239
+ async invoke(toolName, params) {
1240
+ const tool = this.tools.get(toolName);
1241
+ if (!tool) {
1242
+ throw new KadiError(`Tool "${toolName}" not found`, 'TOOL_NOT_FOUND', {
1243
+ toolName,
1244
+ available: Array.from(this.tools.keys()),
1245
+ hint: 'Register the tool first with registerTool()',
1246
+ });
1247
+ }
1248
+ return tool.handler(params);
1249
+ }
1250
+ // ─────────────────────────────────────────────────────────────
1251
+ // ABILITY LOADING
1252
+ // ─────────────────────────────────────────────────────────────
1253
+ /**
1254
+ * Load an in-process ability via dynamic import.
1255
+ *
1256
+ * @param name - Ability name (resolved from agent-lock.json)
1257
+ * @param options - Optional explicit path
1258
+ *
1259
+ * @example
1260
+ * ```typescript
1261
+ * // Load by name (resolves from agent-lock.json)
1262
+ * const calc = await client.loadNative('calculator');
1263
+ *
1264
+ * // Load by explicit path
1265
+ * const calc = await client.loadNative('calculator', { path: './abilities/calc' });
1266
+ * ```
1267
+ */
1268
+ async loadNative(name, options = {}) {
1269
+ // Resolve path from lock file or use explicit path
1270
+ const path = options.path ?? (await resolveAbilityPath(name, options.projectRoot));
1271
+ return loadNativeTransport(path);
1272
+ }
1273
+ /**
1274
+ * Load a child process ability via stdio.
1275
+ *
1276
+ * Three modes of operation:
1277
+ *
1278
+ * 1. **Explicit mode** — Provide `command` and `args` directly (bypasses agent.json):
1279
+ * ```typescript
1280
+ * await client.loadStdio('analyzer', {
1281
+ * command: 'python3',
1282
+ * args: ['./analyzer/main.py', '--verbose'],
1283
+ * });
1284
+ * ```
1285
+ *
1286
+ * 2. **Script selection mode** — Choose which script from ability's agent.json:
1287
+ * ```typescript
1288
+ * await client.loadStdio('analyzer', { script: 'dev' });
1289
+ * // Uses scripts.dev from the ability's agent.json
1290
+ * ```
1291
+ *
1292
+ * 3. **Default mode** — Uses scripts.start from ability's agent.json:
1293
+ * ```typescript
1294
+ * await client.loadStdio('analyzer');
1295
+ * // Resolves from agent-lock.json, reads scripts.start from agent.json
1296
+ * ```
1297
+ *
1298
+ * @param name - Ability name (used for error messages and lock file lookup)
1299
+ * @param options - Command/args override, or script selection
1300
+ */
1301
+ async loadStdio(name, options = {}) {
1302
+ // ─────────────────────────────────────────────────────────────────────────
1303
+ // Mode 1: Explicit command — bypass agent.json entirely
1304
+ // If command is provided, script option is ignored (explicit mode wins)
1305
+ // ─────────────────────────────────────────────────────────────────────────
1306
+ if (options.command) {
1307
+ return loadStdioTransport(options.command, options.args ?? [], {
1308
+ timeoutMs: this.config.requestTimeout,
1309
+ });
1310
+ }
1311
+ // ─────────────────────────────────────────────────────────────────────────
1312
+ // Mode 2 & 3: Resolve script from ability's agent.json
1313
+ // ─────────────────────────────────────────────────────────────────────────
1314
+ const { command, args, cwd } = await resolveAbilityScript(name, options.script ?? 'start', options.projectRoot);
1315
+ return loadStdioTransport(command, args, {
1316
+ timeoutMs: this.config.requestTimeout,
1317
+ cwd, // Run in ability's directory so relative paths work
1318
+ });
1319
+ }
1320
+ /**
1321
+ * Load a remote ability via broker.
1322
+ *
1323
+ * @param name - Ability name to discover on broker
1324
+ * @param options - Broker to use, networks to filter
1325
+ *
1326
+ * @example
1327
+ * ```typescript
1328
+ * // Load from default broker
1329
+ * const ai = await client.loadBroker('text-analyzer');
1330
+ *
1331
+ * // Load from specific broker
1332
+ * const ai = await client.loadBroker('text-analyzer', { broker: 'internal' });
1333
+ * ```
1334
+ */
1335
+ async loadBroker(name, options = {}) {
1336
+ const brokerName = options.broker ?? this.config.defaultBroker;
1337
+ if (!brokerName) {
1338
+ throw new KadiError('No broker specified and no defaultBroker configured', 'INVALID_CONFIG', {
1339
+ hint: 'Either specify a broker in options or set defaultBroker in config',
1340
+ });
1341
+ }
1342
+ const state = this.brokers.get(brokerName);
1343
+ if (!state || state.status !== 'connected') {
1344
+ throw new KadiError(`Broker "${brokerName}" is not connected`, 'BROKER_NOT_CONNECTED', {
1345
+ broker: brokerName,
1346
+ hint: 'Call client.connect() first',
1347
+ });
1348
+ }
1349
+ return loadBrokerTransport(name, {
1350
+ broker: state,
1351
+ requestTimeout: this.config.requestTimeout,
1352
+ networks: options.networks,
1353
+ });
1354
+ }
1355
+ // ─────────────────────────────────────────────────────────────
1356
+ // REMOTE INVOCATION
1357
+ // ─────────────────────────────────────────────────────────────
1358
+ /**
1359
+ * Invoke a tool on a remote agent directly (without loading the ability).
1360
+ *
1361
+ * This uses the KADI async invocation pattern:
1362
+ * 1. Send kadi.ability.request → broker immediately returns { status: 'pending', requestId }
1363
+ * 2. Broker forwards request to provider agent
1364
+ * 3. Provider executes tool and sends result back to broker
1365
+ * 4. Broker sends kadi.ability.response notification with the actual result
1366
+ *
1367
+ * @param toolName - Tool name (e.g., "add"). Broker routes to any provider.
1368
+ * @param params - Tool parameters
1369
+ * @param options - Broker to use, timeout
1370
+ *
1371
+ * @example
1372
+ * ```typescript
1373
+ * // Invoke on default broker (broker finds any provider)
1374
+ * const result = await client.invokeRemote('add', { a: 5, b: 3 });
1375
+ *
1376
+ * // Invoke on specific broker
1377
+ * const result = await client.invokeRemote('analyze', { text: 'hi' }, {
1378
+ * broker: 'internal',
1379
+ * });
1380
+ * ```
1381
+ */
1382
+ async invokeRemote(toolName, params, options = {}) {
1383
+ // ─────────────────────────────────────────────────────────────
1384
+ // VALIDATION
1385
+ // ─────────────────────────────────────────────────────────────
1386
+ const brokerName = options.broker ?? this.config.defaultBroker;
1387
+ if (!brokerName) {
1388
+ throw new KadiError('No broker specified and no defaultBroker configured', 'INVALID_CONFIG', {
1389
+ hint: 'Either specify a broker in options or set defaultBroker in config',
1390
+ });
1391
+ }
1392
+ const state = this.brokers.get(brokerName);
1393
+ if (!state || state.status !== 'connected') {
1394
+ throw new KadiError(`Broker "${brokerName}" is not connected`, 'BROKER_NOT_CONNECTED', {
1395
+ broker: brokerName,
1396
+ hint: 'Call client.connect() first',
1397
+ });
1398
+ }
1399
+ const timeout = options.timeout ?? this.config.requestTimeout;
1400
+ // ─────────────────────────────────────────────────────────────
1401
+ // PHASE 1: Send request, get "pending" acknowledgment
1402
+ // ─────────────────────────────────────────────────────────────
1403
+ // Use existing sendRequest() for the request/response part.
1404
+ // This handles the JSON-RPC id matching for us.
1405
+ const pendingResult = await this.sendRequest(state, {
1406
+ jsonrpc: '2.0',
1407
+ id: ++this.nextRequestId,
1408
+ method: 'kadi.ability.request',
1409
+ params: { toolName, toolInput: params },
1410
+ });
1411
+ // Validate the pending response
1412
+ if (pendingResult.status !== 'pending' || !pendingResult.requestId) {
1413
+ throw new KadiError('Unexpected response from broker: expected pending acknowledgment', 'BROKER_ERROR', {
1414
+ broker: brokerName,
1415
+ toolName,
1416
+ response: pendingResult,
1417
+ });
1418
+ }
1419
+ const requestId = pendingResult.requestId;
1420
+ // ─────────────────────────────────────────────────────────────
1421
+ // PHASE 2: Wait for kadi.ability.response notification
1422
+ // ─────────────────────────────────────────────────────────────
1423
+ // The actual result comes as a notification (no id) with our requestId.
1424
+ // We store in pendingInvocations and handleAbilityResponse() will resolve.
1425
+ return new Promise((resolve, reject) => {
1426
+ const timeoutHandle = setTimeout(() => {
1427
+ state.pendingInvocations.delete(requestId);
1428
+ reject(new KadiError(`Tool invocation "${toolName}" timed out waiting for result`, 'BROKER_TIMEOUT', {
1429
+ broker: brokerName,
1430
+ toolName,
1431
+ requestId,
1432
+ timeout,
1433
+ }));
1434
+ }, timeout);
1435
+ state.pendingInvocations.set(requestId, {
1436
+ resolve: (result) => resolve(result),
1437
+ reject,
1438
+ timeout: timeoutHandle,
1439
+ toolName,
1440
+ sentAt: new Date(),
1441
+ });
1442
+ });
1443
+ }
1444
+ // ─────────────────────────────────────────────────────────────
1445
+ // SERVE MODE
1446
+ // ─────────────────────────────────────────────────────────────
1447
+ /**
1448
+ * Start serving this agent.
1449
+ *
1450
+ * @param mode - 'stdio' for JSON-RPC over stdin/stdout, 'broker' for broker connection
1451
+ *
1452
+ * @example
1453
+ * ```typescript
1454
+ * // Serve as a stdio server (for loadStdio)
1455
+ * await client.serve('stdio');
1456
+ *
1457
+ * // Serve via broker (connects and waits)
1458
+ * await client.serve('broker');
1459
+ * ```
1460
+ */
1461
+ async serve(mode) {
1462
+ if (mode === 'stdio') {
1463
+ await this.serveStdio();
1464
+ }
1465
+ else {
1466
+ await this.serveBroker();
1467
+ }
1468
+ }
1469
+ /**
1470
+ * Serve as a stdio server (JSON-RPC over stdin/stdout).
1471
+ *
1472
+ * Steps:
1473
+ * 1. Redirect console.log to stderr (keeps stdout clean for JSON-RPC)
1474
+ * 2. Set up binary-safe buffer for message parsing
1475
+ * 3. Set up signal handlers for graceful shutdown
1476
+ * 4. Process incoming messages
1477
+ */
1478
+ async serveStdio() {
1479
+ // Mark that we're serving stdio (for emit() to know it can write to stdout)
1480
+ this.isServingStdio = true;
1481
+ // Step 1: Redirect console.log to stderr (keeps stdout clean for JSON-RPC)
1482
+ // Without this, any console.log() in tool handlers corrupts the protocol
1483
+ const originalLog = console.log;
1484
+ console.log = console.error;
1485
+ // Step 2: Binary-safe buffer and constants
1486
+ let buffer = Buffer.alloc(0);
1487
+ const HEADER_END = '\r\n\r\n';
1488
+ const HEADER_END_LENGTH = 4;
1489
+ const CONTENT_LENGTH_PATTERN = /Content-Length:\s*(\d+)/;
1490
+ /**
1491
+ * Try to read a single complete message from the buffer.
1492
+ */
1493
+ const tryReadSingleMessage = () => {
1494
+ const headerEndIndex = buffer.indexOf(HEADER_END);
1495
+ if (headerEndIndex === -1) {
1496
+ return null;
1497
+ }
1498
+ const header = buffer.slice(0, headerEndIndex).toString('utf8');
1499
+ const match = header.match(CONTENT_LENGTH_PATTERN);
1500
+ if (!match?.[1]) {
1501
+ // Malformed header - log and clear buffer (don't silently recover)
1502
+ console.error('[KADI Stdio Server] Malformed header - missing Content-Length');
1503
+ buffer = Buffer.alloc(0);
1504
+ return null;
1505
+ }
1506
+ const contentLength = parseInt(match[1], 10);
1507
+ const contentStart = headerEndIndex + HEADER_END_LENGTH;
1508
+ const contentEnd = contentStart + contentLength;
1509
+ if (buffer.length < contentEnd) {
1510
+ return null;
1511
+ }
1512
+ const content = buffer.slice(contentStart, contentEnd).toString('utf8');
1513
+ buffer = buffer.slice(contentEnd);
1514
+ try {
1515
+ return JSON.parse(content);
1516
+ }
1517
+ catch {
1518
+ console.error('[KADI Stdio Server] Invalid JSON in message');
1519
+ return null;
1520
+ }
1521
+ };
1522
+ // Step 3: Graceful shutdown handler
1523
+ // CRITICAL: Disconnects loaded abilities to prevent orphan processes
1524
+ const cleanup = async () => {
1525
+ console.error('[KADI Stdio Server] Shutting down...');
1526
+ await this.disconnect();
1527
+ console.log = originalLog;
1528
+ process.exit(0);
1529
+ };
1530
+ process.once('SIGTERM', cleanup);
1531
+ process.once('SIGINT', cleanup);
1532
+ // Step 4: Process incoming messages
1533
+ process.stdin.on('data', (chunk) => {
1534
+ buffer = Buffer.concat([buffer, chunk]);
1535
+ let message;
1536
+ while ((message = tryReadSingleMessage()) !== null) {
1537
+ // Handle message asynchronously but don't await (allows concurrent processing)
1538
+ this.handleStdioMessage(message, cleanup).catch((err) => {
1539
+ console.error('[KADI Stdio Server] Error handling message:', err);
1540
+ });
1541
+ }
1542
+ });
1543
+ // Keep process alive indefinitely - shutdown happens via signal handlers
1544
+ await new Promise(() => { });
1545
+ }
1546
+ /**
1547
+ * Handle a stdio message.
1548
+ */
1549
+ async handleStdioMessage(message, cleanup) {
1550
+ let result;
1551
+ let error;
1552
+ try {
1553
+ if (message.method === 'readAgentJson') {
1554
+ result = this.readAgentJson();
1555
+ }
1556
+ else if (message.method === 'invoke') {
1557
+ const params = message.params;
1558
+ result = await this.invoke(params.toolName, params.toolInput);
1559
+ }
1560
+ else if (message.method === 'shutdown') {
1561
+ // Graceful shutdown - send response first, then cleanup
1562
+ this.sendStdioResponse(message.id, { ok: true });
1563
+ await cleanup();
1564
+ return;
1565
+ }
1566
+ else {
1567
+ error = { code: -32601, message: `Method not found: ${message.method}` };
1568
+ }
1569
+ }
1570
+ catch (e) {
1571
+ error = { code: -32000, message: e instanceof Error ? e.message : String(e) };
1572
+ }
1573
+ if (error) {
1574
+ this.sendStdioError(message.id, error);
1575
+ }
1576
+ else {
1577
+ this.sendStdioResponse(message.id, result);
1578
+ }
1579
+ }
1580
+ /**
1581
+ * Send a response via stdio.
1582
+ */
1583
+ sendStdioResponse(id, result) {
1584
+ const response = {
1585
+ jsonrpc: '2.0',
1586
+ id,
1587
+ result,
1588
+ };
1589
+ const json = JSON.stringify(response);
1590
+ process.stdout.write(`Content-Length: ${Buffer.byteLength(json)}\r\n\r\n${json}`);
1591
+ }
1592
+ /**
1593
+ * Send an error response via stdio.
1594
+ */
1595
+ sendStdioError(id, error) {
1596
+ const response = {
1597
+ jsonrpc: '2.0',
1598
+ id,
1599
+ error,
1600
+ };
1601
+ const json = JSON.stringify(response);
1602
+ process.stdout.write(`Content-Length: ${Buffer.byteLength(json)}\r\n\r\n${json}`);
1603
+ }
1604
+ /**
1605
+ * Serve via broker (connect and wait for requests).
1606
+ *
1607
+ * Connects to configured brokers and waits for incoming tool requests.
1608
+ * Sets up graceful shutdown to disconnect loaded abilities.
1609
+ */
1610
+ async serveBroker() {
1611
+ await this.connect();
1612
+ // Graceful shutdown handler
1613
+ // CRITICAL: Disconnects loaded abilities to prevent orphan processes
1614
+ const cleanup = async () => {
1615
+ console.error('[KADI Broker Server] Shutting down...');
1616
+ await this.disconnect();
1617
+ process.exit(0);
1618
+ };
1619
+ process.once('SIGTERM', cleanup);
1620
+ process.once('SIGINT', cleanup);
1621
+ // Keep process alive indefinitely - shutdown happens via signal handlers
1622
+ await new Promise(() => { });
1623
+ }
1624
+ // ─────────────────────────────────────────────────────────────
1625
+ // UTILITY
1626
+ // ─────────────────────────────────────────────────────────────
1627
+ /**
1628
+ * Get agent information (for readAgentJson protocol).
1629
+ */
1630
+ readAgentJson() {
1631
+ return {
1632
+ name: this.config.name,
1633
+ version: this.config.version,
1634
+ tools: this.getToolDefinitions(),
1635
+ };
1636
+ }
1637
+ /**
1638
+ * Get broker connection state (for broker transport).
1639
+ */
1640
+ getBrokerState(brokerName) {
1641
+ return this.brokers.get(brokerName);
1642
+ }
1643
+ /**
1644
+ * Check if connected to a specific broker or any broker.
1645
+ *
1646
+ * @param brokerName - Optional broker name to check. If not specified, checks any.
1647
+ */
1648
+ isConnected(brokerName) {
1649
+ if (brokerName) {
1650
+ const state = this.brokers.get(brokerName);
1651
+ return state?.status === 'connected';
1652
+ }
1653
+ for (const [, state] of this.brokers) {
1654
+ if (state.status === 'connected') {
1655
+ return true;
1656
+ }
1657
+ }
1658
+ return false;
1659
+ }
1660
+ /**
1661
+ * Get list of connected broker names.
1662
+ */
1663
+ getConnectedBrokers() {
1664
+ const connected = [];
1665
+ for (const [name, state] of this.brokers) {
1666
+ if (state.status === 'connected') {
1667
+ connected.push(name);
1668
+ }
1669
+ }
1670
+ return connected;
1671
+ }
1672
+ }
1673
+ //# sourceMappingURL=client.js.map