agentnet 0.0.1 → 0.0.3

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.
@@ -0,0 +1,532 @@
1
+ /**
2
+ * Redis Transport implementation
3
+ */
4
+ import { Transport, DiscoveryMessage, safeConnect } from './base.js';
5
+ import { Message } from '../index.js';
6
+ import { logger } from '../utils/logger.js';
7
+ import {
8
+ TransportError,
9
+ DiscoveryError,
10
+ HandoffError,
11
+ TimeoutError,
12
+ withTimeout
13
+ } from '../errors/index.js';
14
+
15
+ // Constants
16
+ const HEARTBEAT_INTERVAL = 1000;
17
+ const TIMEOUT_TASK_REQUEST = 60000;
18
+
19
+ /**
20
+ * Redis implementation of the Transport interface
21
+ */
22
+ export class RedisTransport extends Transport {
23
+ constructor() {
24
+ super('Redis');
25
+ this.client = null;
26
+ this.pubClient = null;
27
+ this.subClient = null;
28
+ this.subscriptions = new Map(); // Map of subscription patterns to handlers
29
+ this.requestMap = new Map(); // For request-response pattern
30
+ }
31
+
32
+ /**
33
+ * Connect to Redis
34
+ * @param {Object} config - Redis connection configuration
35
+ * @returns {Promise<any>} - The Redis connection client
36
+ */
37
+ async connect(config) {
38
+ if (this.connected && this.client) {
39
+ return this.client;
40
+ }
41
+
42
+ try {
43
+ // Implementation will depend on the Redis client library used
44
+ // For example, with ioredis:
45
+ // this.client = new Redis(config);
46
+ // this.pubClient = new Redis(config);
47
+ // this.subClient = new Redis(config);
48
+
49
+ // Set up subscription handling
50
+ // this.subClient.on('message', (channel, message) => {
51
+ // const handler = this.subscriptions.get(channel);
52
+ // if (handler) {
53
+ // handler(channel, message);
54
+ // }
55
+ // });
56
+
57
+ this.connected = true;
58
+ return this.client;
59
+ } catch (error) {
60
+ throw new TransportError(
61
+ `Failed to connect to Redis: ${error.message}`,
62
+ this.transportType,
63
+ { details: error.message }
64
+ );
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Disconnect from Redis
70
+ * @returns {Promise<void>}
71
+ */
72
+ async disconnect() {
73
+ await super.disconnect();
74
+
75
+ try {
76
+ if (this.client) await this.client.quit();
77
+ if (this.pubClient) await this.pubClient.quit();
78
+ if (this.subClient) await this.subClient.quit();
79
+
80
+ this.client = null;
81
+ this.pubClient = null;
82
+ this.subClient = null;
83
+ this.subscriptions.clear();
84
+ } catch (error) {
85
+ logger.warn('Error disconnecting from Redis', { error });
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Publish a message to a Redis channel
91
+ * @param {string} channel - The channel to publish to
92
+ * @param {string} message - The message to publish
93
+ * @returns {Promise<void>}
94
+ */
95
+ async publish(channel, message) {
96
+ if (!this.connected || !this.pubClient) {
97
+ throw new TransportError(
98
+ 'Cannot publish: not connected to Redis',
99
+ this.transportType
100
+ );
101
+ }
102
+
103
+ try {
104
+ // await this.pubClient.publish(channel, message);
105
+ } catch (error) {
106
+ throw new TransportError(
107
+ `Failed to publish to channel ${channel}: ${error.message}`,
108
+ this.transportType,
109
+ { channel }
110
+ );
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Subscribe to a Redis channel
116
+ * @param {string} channel - The channel to subscribe to
117
+ * @param {Object} options - Subscription options
118
+ * @returns {Promise<string>} - The subscription identifier
119
+ */
120
+ async subscribe(channel, options = {}, handler = null) {
121
+ if (!this.connected || !this.subClient) {
122
+ throw new TransportError(
123
+ 'Cannot subscribe: not connected to Redis',
124
+ this.transportType
125
+ );
126
+ }
127
+
128
+ try {
129
+ // await this.subClient.subscribe(channel);
130
+
131
+ if (handler) {
132
+ this.subscriptions.set(channel, handler);
133
+ }
134
+
135
+ return channel; // Return the channel as the subscription ID
136
+ } catch (error) {
137
+ throw new TransportError(
138
+ `Failed to subscribe to channel ${channel}: ${error.message}`,
139
+ this.transportType,
140
+ { channel }
141
+ );
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Unsubscribe from a Redis channel
147
+ * @param {string} channel - The channel to unsubscribe from
148
+ * @returns {Promise<void>}
149
+ */
150
+ async unsubscribe(channel) {
151
+ if (!this.connected || !this.subClient) {
152
+ return; // Already disconnected
153
+ }
154
+
155
+ try {
156
+ // await this.subClient.unsubscribe(channel);
157
+ this.subscriptions.delete(channel);
158
+ } catch (error) {
159
+ logger.warn(`Error unsubscribing from channel ${channel}`, { error });
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Send a request and wait for a response
165
+ * Implements request-response pattern using Redis PubSub and unique reply channels
166
+ * @param {string} target - The target channel
167
+ * @param {string} message - The message to send
168
+ * @param {Object} options - Request options
169
+ * @returns {Promise<any>} - The response
170
+ */
171
+ async request(target, message, options = {}) {
172
+ if (!this.connected || !this.pubClient || !this.subClient) {
173
+ throw new TransportError(
174
+ 'Cannot send request: not connected to Redis',
175
+ this.transportType
176
+ );
177
+ }
178
+
179
+ const correlationId = `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
180
+ const replyChannel = `${target}_reply_${correlationId}`;
181
+ const timeout = options.timeout || TIMEOUT_TASK_REQUEST;
182
+
183
+ try {
184
+ // Create a promise that will be resolved when the response is received
185
+ const responsePromise = new Promise((resolve, reject) => {
186
+ // Set a timeout
187
+ const timeoutId = setTimeout(() => {
188
+ this.unsubscribe(replyChannel).catch(() => {});
189
+ this.requestMap.delete(correlationId);
190
+ reject(new TimeoutError(`Request to ${target} timed out after ${timeout}ms`));
191
+ }, timeout);
192
+
193
+ // Store the resolver and timeout
194
+ this.requestMap.set(correlationId, {
195
+ resolve,
196
+ reject,
197
+ timeoutId
198
+ });
199
+
200
+ // Set up a handler for the reply
201
+ const replyHandler = (channel, reply) => {
202
+ const requestData = this.requestMap.get(correlationId);
203
+ if (requestData) {
204
+ clearTimeout(requestData.timeoutId);
205
+ this.requestMap.delete(correlationId);
206
+ this.unsubscribe(replyChannel).catch(() => {});
207
+ resolve({ string: () => reply });
208
+ }
209
+ };
210
+
211
+ // Subscribe to the reply channel
212
+ this.subscribe(replyChannel, {}, replyHandler).catch(reject);
213
+
214
+ // Send the request as JSON with correlation ID and reply channel
215
+ const requestData = JSON.stringify({
216
+ data: message,
217
+ metadata: {
218
+ correlationId,
219
+ replyChannel
220
+ }
221
+ });
222
+
223
+ this.publish(target, requestData).catch(reject);
224
+ });
225
+
226
+ // Wait for the response
227
+ return await responsePromise;
228
+ } catch (error) {
229
+ // Clean up
230
+ this.unsubscribe(replyChannel).catch(() => {});
231
+ const requestData = this.requestMap.get(correlationId);
232
+ if (requestData) {
233
+ clearTimeout(requestData.timeoutId);
234
+ this.requestMap.delete(correlationId);
235
+ }
236
+
237
+ throw new TransportError(
238
+ `Request to ${target} failed: ${error.message}`,
239
+ this.transportType,
240
+ { target }
241
+ );
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Set up discovery subscription
247
+ * @param {string} discoveryChannel - The channel for discovery messages
248
+ * @param {string} namespace - The agent namespace
249
+ * @param {string} agentName - The agent name
250
+ * @param {Object} discoveredAgents - Map to store discovered agents
251
+ * @param {Array} acceptedNetworks - List of accepted networks
252
+ * @returns {Promise<void>}
253
+ */
254
+ async setupDiscoverySubscription(discoveryChannel, namespace, agentName, discoveredAgents, acceptedNetworks) {
255
+ try {
256
+ // Process discovery message handler
257
+ const discoveryHandler = async (channel, message) => {
258
+ try {
259
+ // Parse and validate the discovery message
260
+ let discoveryMessage;
261
+ try {
262
+ discoveryMessage = DiscoveryMessage.fromString(message);
263
+ } catch (parseError) {
264
+ logger.warn('Invalid discovery message format', { error: parseError.message });
265
+ return;
266
+ }
267
+
268
+ const network = discoveryMessage.network;
269
+ const networkNamespace = network.split(".")[0];
270
+ const networkName = network.split(".")[1];
271
+
272
+ // Skip self
273
+ if (network === `${namespace}.${agentName}`) {
274
+ return;
275
+ }
276
+
277
+ // Check if network is accepted
278
+ let isAccepted = false;
279
+ for (const acceptedNetwork of acceptedNetworks) {
280
+ const acceptedNetworkNamespace = acceptedNetwork.split(".")[0];
281
+ const acceptedNetworkName = acceptedNetwork.split(".")[1];
282
+
283
+ if (
284
+ (acceptedNetworkNamespace === '*' || acceptedNetworkNamespace === networkNamespace) &&
285
+ (acceptedNetworkName === '*' || acceptedNetworkName === networkName)
286
+ ) {
287
+ isAccepted = true;
288
+ break;
289
+ }
290
+ }
291
+
292
+ if (!isAccepted) {
293
+ logger.warn(`Agent ${agentName} does not accept network ${network}`);
294
+ return;
295
+ }
296
+
297
+ // Process the schemas from the discovery message
298
+ for (const schema of discoveryMessage.schemas) {
299
+ // Skip invalid schemas
300
+ if (!schema || !schema.name) {
301
+ logger.warn('Invalid schema in discovery payload', { schema });
302
+ continue;
303
+ }
304
+
305
+ const agentKey = `${network}-${schema.name}`;
306
+
307
+ if (discoveryMessage.agentName !== agentName && !discoveredAgents[agentKey]) {
308
+ logger.info(`${agentName} discovered agent capability: ${discoveryMessage.agentName} with capability ${schema.name}`);
309
+
310
+ const handoffFunction = async (conversation, state, input) => {
311
+ try {
312
+ // Use withTimeout to ensure handoffs don't hang
313
+ return await withTimeout(
314
+ async () => {
315
+ try {
316
+ const message = new Message({
317
+ session: state,
318
+ content: input
319
+ });
320
+ const req = await this.request(
321
+ discoveryMessage.agentName,
322
+ message.serialize(),
323
+ { timeout: TIMEOUT_TASK_REQUEST }
324
+ );
325
+ return req.string();
326
+ } catch (error) {
327
+ throw new HandoffError(
328
+ `Handoff to agent ${discoveryMessage.agentName} failed: ${error.message}`,
329
+ agentName,
330
+ discoveryMessage.agentName,
331
+ { schemaName: schema.name }
332
+ );
333
+ }
334
+ },
335
+ TIMEOUT_TASK_REQUEST,
336
+ `handoff to ${discoveryMessage.agentName}`
337
+ );
338
+ } catch (error) {
339
+ logger.error(`Handoff error to ${discoveryMessage.agentName}`, {
340
+ error,
341
+ schema: schema.name
342
+ });
343
+ throw error;
344
+ }
345
+ };
346
+
347
+ discoveredAgents[agentKey] = {
348
+ name: schema.name,
349
+ schema: schema,
350
+ function: handoffFunction
351
+ };
352
+ }
353
+ }
354
+ } catch (error) {
355
+ logger.error('Error processing discovery message', { error });
356
+ }
357
+ };
358
+
359
+ // Subscribe to discovery channel
360
+ await this.subscribe(discoveryChannel, {}, discoveryHandler);
361
+ logger.info(`Agent ${agentName} subscribed to discovery channel ${discoveryChannel}`);
362
+ } catch (error) {
363
+ throw new DiscoveryError(
364
+ `Failed to set up discovery subscription on channel ${discoveryChannel}`,
365
+ { agentName, channel: discoveryChannel },
366
+ error
367
+ );
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Set up task handler for processing incoming requests
373
+ * @param {string} agentName - The agent name (used as the channel)
374
+ * @param {Function} processingFunction - The function to process requests
375
+ * @returns {Promise<void>}
376
+ */
377
+ async setupTaskHandler(agentName, processingFunction) {
378
+ try {
379
+ const taskHandler = async (channel, message) => {
380
+ try {
381
+ // Parse the request message
382
+ const request = JSON.parse(message);
383
+ const payload = JSON.parse(request.data);
384
+ const metadata = request.metadata || {};
385
+ const replyChannel = metadata.replyChannel;
386
+
387
+ // Process the message using the Message class
388
+ const msg = new Message(payload);
389
+
390
+ // Process the task with timeout
391
+ const response = await withTimeout(
392
+ async () => processingFunction(msg),
393
+ TIMEOUT_TASK_REQUEST * 2,
394
+ `task processing for ${agentName}`
395
+ );
396
+
397
+ // Send response back if reply channel is available
398
+ if (replyChannel) {
399
+ await this.publish(replyChannel, response.serialize());
400
+ }
401
+ } catch (error) {
402
+ logger.error("Error processing task", { error, agentName, channel });
403
+
404
+ // Send error response back if reply channel is available
405
+ const request = JSON.parse(message);
406
+ const metadata = request.metadata || {};
407
+ const replyChannel = metadata.replyChannel;
408
+
409
+ if (replyChannel) {
410
+ await this.publish(replyChannel, JSON.stringify({
411
+ error: true,
412
+ message: error.message,
413
+ type: error.name || 'Error'
414
+ }));
415
+ }
416
+ }
417
+ };
418
+
419
+ // Subscribe to the agent's channel for tasks
420
+ await this.subscribe(agentName, {}, taskHandler);
421
+ logger.info(`Agent ${agentName} subscribed for task handling`);
422
+ } catch (error) {
423
+ throw new TransportError(
424
+ `Failed to set up task handler for ${agentName}: ${error.message}`,
425
+ this.transportType,
426
+ { agentName }
427
+ );
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Creates a runtime for agent communication using Redis
433
+ * @param {string} namespace - The agent namespace
434
+ * @param {string} agentName - The agent name
435
+ * @param {Array} discoverySchemas - The agent capability schemas for discovery
436
+ * @param {Object} config - Additional configuration
437
+ * @returns {Promise<Object>} - The runtime { handleTask, discoveredAgents }
438
+ */
439
+ async createRuntime(namespace, agentName, discoverySchemas, config) {
440
+ const discoveredAgents = {};
441
+
442
+ try {
443
+ // Verify configuration
444
+ if (!config || !config.bindings || !config.bindings.discoveryTopic) {
445
+ throw new TransportError(
446
+ 'Missing required Redis configuration: discoveryTopic',
447
+ this.transportType,
448
+ { agentName }
449
+ );
450
+ }
451
+
452
+ const discoveryChannel = config.bindings.discoveryTopic;
453
+ const acceptedNetworks = config.bindings.acceptedNetworks || [];
454
+ logger.info(`Agent ${agentName} initialized with discovery channel ${discoveryChannel}`);
455
+
456
+ // Step 1: Subscribe to discovery channel
457
+ await this.setupDiscoverySubscription(discoveryChannel, namespace, agentName, discoveredAgents, acceptedNetworks);
458
+
459
+ // Step 2: Set up heartbeat
460
+ this.setupHeartbeat(discoveryChannel, namespace, agentName, discoverySchemas, HEARTBEAT_INTERVAL);
461
+
462
+ // Step 3: Create task handler function
463
+ const handleTask = async (fn) => {
464
+ if (typeof fn !== 'function') {
465
+ throw new Error('Task handler must be a function');
466
+ }
467
+ await this.setupTaskHandler(agentName, fn);
468
+ };
469
+
470
+ return { handleTask, discoveredAgents };
471
+ } catch (error) {
472
+ // Enhance the error with context if it's not already a TransportError
473
+ if (!(error instanceof TransportError)) {
474
+ error = new TransportError(
475
+ `Failed to initialize Redis runtime: ${error.message}`,
476
+ this.transportType,
477
+ { agentName }
478
+ );
479
+ }
480
+
481
+ logger.error("Redis runtime initialization failed", { error, agentName });
482
+ throw error;
483
+ }
484
+ }
485
+ }
486
+
487
+ /**
488
+ * Factory function to create a Redis transport instance
489
+ * @returns {RedisTransport} - A Redis transport instance
490
+ */
491
+ export function createRedisTransport() {
492
+ return new RedisTransport();
493
+ }
494
+
495
+ /**
496
+ * Adapter function to create a Redis-based runtime for agent communication
497
+ * @param {string} namespace - The agent namespace
498
+ * @param {string} agentName - The agent name
499
+ * @param {Array} ioInterfaces - The IO interfaces (only the first one is used)
500
+ * @param {Array} discoverySchemas - The agent capability schemas for discovery
501
+ * @returns {Promise<Object>} - The runtime { handleTask, discoveredAgents }
502
+ */
503
+ export async function RedisIOAgentRuntime(namespace, agentName, ioInterfaces, discoverySchemas) {
504
+ if (ioInterfaces.length > 1) {
505
+ throw new TransportError(
506
+ 'Only one IO Redis interface is supported',
507
+ 'Redis',
508
+ { agentName, interfacesCount: ioInterfaces.length }
509
+ );
510
+ }
511
+
512
+ if (ioInterfaces.length === 0) {
513
+ logger.warn(`No Redis interfaces provided for agent ${agentName}, creating passive runtime`);
514
+ return { handleTask: async () => {}, discoveredAgents: {} };
515
+ }
516
+
517
+ const io = ioInterfaces[0];
518
+ const transport = createRedisTransport();
519
+
520
+ try {
521
+ // Connect to Redis with retry logic
522
+ logger.info(`Connecting to Redis for agent ${agentName}`);
523
+ await safeConnect(transport, io.config);
524
+
525
+ // Create runtime with the transport
526
+ return await transport.createRuntime(namespace, agentName, discoverySchemas, io.config);
527
+ } catch (error) {
528
+ // Make sure to clean up if initialization fails
529
+ await transport.disconnect();
530
+ throw error;
531
+ }
532
+ }