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