@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/dist/client.d.ts +84 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +229 -0
- package/dist/client.js.map +1 -1
- package/dist/crypto.d.ts +88 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +120 -0
- package/dist/crypto.js.map +1 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +6 -1
- package/src/client.ts +2380 -0
- package/src/crypto.ts +153 -0
- package/src/errors.ts +292 -0
- package/src/index.ts +114 -0
- package/src/lockfile.ts +493 -0
- package/src/transports/broker.ts +682 -0
- package/src/transports/native.ts +307 -0
- package/src/transports/stdio.ts +580 -0
- package/src/types.ts +1011 -0
- package/src/zod.ts +69 -0
- package/tsconfig.json +52 -0
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
|
+
}
|