gopherhole_openclaw_a2a 0.1.2

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,605 @@
1
+ /**
2
+ * A2A Connection Manager
3
+ * Handles WebSocket connections to other agents and the bridge
4
+ */
5
+
6
+ import WebSocket from 'ws';
7
+ import { v4 as uuidv4 } from 'uuid';
8
+ import type {
9
+ A2AMessage,
10
+ A2APendingRequest,
11
+ A2AResponse,
12
+ A2AChannelConfig,
13
+ } from './types.js';
14
+
15
+ // Use ws module's WebSocket type directly
16
+ interface A2AConnection {
17
+ id: string;
18
+ name: string;
19
+ url: string;
20
+ ws: WebSocket | null;
21
+ connected: boolean;
22
+ lastPingAt?: number;
23
+ reconnectAttempts: number;
24
+ }
25
+
26
+ export type MessageHandler = (agentId: string, message: A2AMessage) => Promise<void>;
27
+
28
+ export class A2AConnectionManager {
29
+ private connections: Map<string, A2AConnection> = new Map();
30
+ private pendingRequests: Map<string, A2APendingRequest> = new Map();
31
+ private reconnectTimers: Map<string, NodeJS.Timeout> = new Map();
32
+ private messageHandler: MessageHandler | null = null;
33
+ private config: A2AChannelConfig;
34
+ private agentId: string;
35
+
36
+ constructor(config: A2AChannelConfig) {
37
+ this.config = config;
38
+ this.agentId = config.agentId ?? 'clawdbot';
39
+ }
40
+
41
+ setMessageHandler(handler: MessageHandler): void {
42
+ this.messageHandler = handler;
43
+ }
44
+
45
+ async start(): Promise<void> {
46
+ // Connect to direct agents
47
+ if (this.config.agents) {
48
+ for (const agent of this.config.agents) {
49
+ await this.connectToAgent(agent.id, agent.url, agent.name);
50
+ }
51
+ }
52
+
53
+ // Connect to bridge if configured
54
+ if (this.config.bridgeUrl) {
55
+ await this.connectToAgent('bridge', this.config.bridgeUrl, 'A2A Bridge');
56
+ }
57
+
58
+ // Connect to GopherHole if configured
59
+ if (this.config.gopherhole?.enabled && this.config.gopherhole?.apiKey) {
60
+ await this.connectToGopherHole();
61
+ }
62
+ }
63
+
64
+ private async connectToGopherHole(): Promise<void> {
65
+ const gphConfig = this.config.gopherhole!;
66
+ const hubUrl = gphConfig.hubUrl || 'wss://gopherhole.helixdata.workers.dev/ws';
67
+
68
+ const conn: A2AConnection = {
69
+ id: 'gopherhole',
70
+ name: 'GopherHole Hub',
71
+ url: hubUrl,
72
+ ws: null,
73
+ connected: false,
74
+ reconnectAttempts: 0,
75
+ };
76
+
77
+ try {
78
+ await this.establishGopherHoleConnection(conn, gphConfig.apiKey);
79
+ this.connections.set('gopherhole', conn);
80
+ console.log(`[a2a] Connected to GopherHole Hub`);
81
+ } catch (err) {
82
+ console.error(`[a2a] Failed to connect to GopherHole:`, (err as Error).message);
83
+ this.connections.set('gopherhole', conn);
84
+ this.scheduleReconnect('gopherhole');
85
+ }
86
+ }
87
+
88
+ private async establishGopherHoleConnection(conn: A2AConnection, apiKey: string): Promise<void> {
89
+ return new Promise((resolve, reject) => {
90
+ const ws = new WebSocket(conn.url);
91
+
92
+ const timeout = setTimeout(() => {
93
+ ws.terminate();
94
+ reject(new Error('GopherHole connection timeout'));
95
+ }, 10000);
96
+
97
+ ws.on('open', () => {
98
+ console.log('[a2a] GopherHole connected, authenticating...');
99
+ ws.send(JSON.stringify({ type: 'auth', token: apiKey }));
100
+ });
101
+
102
+ ws.on('message', (data) => {
103
+ try {
104
+ const msg = JSON.parse(data.toString());
105
+
106
+ if (msg.type === 'auth_ok' || msg.type === 'welcome') {
107
+ clearTimeout(timeout);
108
+ conn.ws = ws;
109
+ conn.connected = true;
110
+ conn.reconnectAttempts = 0;
111
+ conn.lastPingAt = Date.now();
112
+ console.log(`[a2a] GopherHole authenticated as ${msg.agentId}`);
113
+ resolve();
114
+ } else if (msg.type === 'auth_error') {
115
+ clearTimeout(timeout);
116
+ reject(new Error(msg.error || 'GopherHole auth failed'));
117
+ } else if (msg.type === 'message') {
118
+ // Incoming message from another agent via GopherHole
119
+ console.log(`[a2a] Received GopherHole message: taskId=${msg.taskId}, from=${msg.from}`);
120
+ const a2aMsg: A2AMessage = {
121
+ type: 'message',
122
+ taskId: msg.taskId || `gph-${Date.now()}`,
123
+ from: msg.from,
124
+ content: msg.payload,
125
+ };
126
+ this.handleMessage('gopherhole', a2aMsg);
127
+ } else if (msg.jsonrpc === '2.0' && msg.result) {
128
+ // JSON-RPC response - task status (may be immediate success/failure)
129
+ const requestId = msg.id;
130
+ const taskId = msg.result?.id;
131
+ const state = msg.result?.status?.state;
132
+ console.log(`[a2a] GopherHole JSON-RPC response: requestId=${requestId}, taskId=${taskId}, state=${state}`);
133
+
134
+ // Handle terminal states immediately (use requestId - that's what we stored)
135
+ if (state === 'completed' || state === 'failed') {
136
+ this.resolveGopherHoleTask(requestId, msg.result);
137
+ return;
138
+ }
139
+
140
+ // For 'working'/'submitted', map taskId for future task_update events
141
+ if (taskId && requestId && taskId !== requestId) {
142
+ const pending = this.pendingRequests.get(requestId);
143
+ if (pending) {
144
+ this.pendingRequests.set(taskId, pending);
145
+ this.pendingRequests.delete(requestId);
146
+ console.log(`[a2a] Mapped taskId ${taskId} from requestId ${requestId}`);
147
+ }
148
+ }
149
+ } else if (msg.jsonrpc === '2.0' && msg.error) {
150
+ // JSON-RPC error response
151
+ const requestId = msg.id;
152
+ console.log(`[a2a] GopherHole JSON-RPC error: id=${requestId}, error=${msg.error?.message}`);
153
+
154
+ const pending = this.pendingRequests.get(requestId);
155
+ if (pending) {
156
+ clearTimeout(pending.timeout);
157
+ this.pendingRequests.delete(requestId);
158
+ pending.reject(new Error(msg.error?.message || 'RPC error'));
159
+ }
160
+ } else if (msg.type === 'task_update') {
161
+ // Task update event - nested under msg.task
162
+ const task = msg.task || msg;
163
+ const taskId = task.id || task.taskId;
164
+ const state = task.status?.state;
165
+ console.log(`[a2a] GopherHole task_update: taskId=${taskId}, state=${state}`);
166
+
167
+ if (state === 'completed' || state === 'failed') {
168
+ this.resolveGopherHoleTask(taskId, task);
169
+ }
170
+ } else if (msg.type === 'error') {
171
+ // Error response (e.g., AGENT_NOT_FOUND)
172
+ const taskId = msg.taskId;
173
+ console.log(`[a2a] GopherHole error: code=${msg.code}, message=${msg.message}, taskId=${taskId}`);
174
+
175
+ if (taskId) {
176
+ const pending = this.pendingRequests.get(taskId);
177
+ if (pending) {
178
+ clearTimeout(pending.timeout);
179
+ this.pendingRequests.delete(taskId);
180
+ pending.reject(new Error(msg.message || msg.code || 'Unknown error'));
181
+ }
182
+ }
183
+ } else {
184
+ // Unhandled message type - log for debugging
185
+ console.log(`[a2a] Unhandled GopherHole message: ${JSON.stringify(msg).slice(0, 500)}`);
186
+ }
187
+ } catch (err) {
188
+ console.error(`[a2a] Failed to parse GopherHole message:`, err);
189
+ }
190
+ });
191
+
192
+ ws.on('close', (code, reason) => {
193
+ conn.connected = false;
194
+ conn.ws = null;
195
+ console.log(`[a2a] Disconnected from GopherHole: ${code} ${reason?.toString()}`);
196
+ this.scheduleReconnect('gopherhole');
197
+ });
198
+
199
+ ws.on('error', (err) => {
200
+ clearTimeout(timeout);
201
+ console.error(`[a2a] GopherHole WebSocket error:`, err.message);
202
+ if (!conn.connected) {
203
+ reject(err);
204
+ }
205
+ });
206
+ });
207
+ }
208
+
209
+ async stop(): Promise<void> {
210
+ // Clear all reconnect timers
211
+ for (const timer of this.reconnectTimers.values()) {
212
+ clearTimeout(timer);
213
+ }
214
+ this.reconnectTimers.clear();
215
+
216
+ // Clear pending requests
217
+ for (const pending of this.pendingRequests.values()) {
218
+ clearTimeout(pending.timeout);
219
+ pending.reject(new Error('Connection closing'));
220
+ }
221
+ this.pendingRequests.clear();
222
+
223
+ // Close all connections
224
+ for (const conn of this.connections.values()) {
225
+ if (conn.ws && conn.ws.readyState === WebSocket.OPEN) {
226
+ conn.ws.close(1000, 'Shutting down');
227
+ }
228
+ }
229
+ this.connections.clear();
230
+ }
231
+
232
+ private async connectToAgent(id: string, url: string, name?: string): Promise<void> {
233
+ const conn: A2AConnection = {
234
+ id,
235
+ name: name ?? id,
236
+ url,
237
+ ws: null,
238
+ connected: false,
239
+ reconnectAttempts: 0,
240
+ };
241
+
242
+ try {
243
+ await this.establishConnection(conn);
244
+ this.connections.set(id, conn);
245
+ console.log(`[a2a] Connected to agent: ${conn.name} (${id})`);
246
+ } catch (err) {
247
+ console.error(`[a2a] Failed to connect to ${id}:`, (err as Error).message);
248
+ this.connections.set(id, conn);
249
+ this.scheduleReconnect(id);
250
+ }
251
+ }
252
+
253
+ private async establishConnection(conn: A2AConnection): Promise<void> {
254
+ return new Promise((resolve, reject) => {
255
+ const ws = new WebSocket(conn.url);
256
+
257
+ const timeout = setTimeout(() => {
258
+ ws.terminate();
259
+ reject(new Error('Connection timeout'));
260
+ }, 10000);
261
+
262
+ ws.on('open', () => {
263
+ clearTimeout(timeout);
264
+ conn.ws = ws;
265
+ conn.connected = true;
266
+ conn.reconnectAttempts = 0;
267
+ conn.lastPingAt = Date.now();
268
+
269
+ // Send agent card announcement
270
+ this.sendAgentAnnounce(conn);
271
+ resolve();
272
+ });
273
+
274
+ ws.on('message', (data) => {
275
+ try {
276
+ const message = JSON.parse(data.toString()) as A2AMessage;
277
+ this.handleMessage(conn.id, message);
278
+ } catch (err) {
279
+ console.error(`[a2a] Failed to parse message from ${conn.id}:`, err);
280
+ }
281
+ });
282
+
283
+ ws.on('close', (code, reason) => {
284
+ conn.connected = false;
285
+ conn.ws = null;
286
+ console.log(`[a2a] Disconnected from ${conn.id}: ${code} ${reason?.toString()}`);
287
+ this.scheduleReconnect(conn.id);
288
+ });
289
+
290
+ ws.on('error', (err) => {
291
+ clearTimeout(timeout);
292
+ console.error(`[a2a] WebSocket error for ${conn.id}:`, err.message);
293
+ reject(err);
294
+ });
295
+
296
+ ws.on('ping', () => {
297
+ conn.lastPingAt = Date.now();
298
+ ws.pong();
299
+ });
300
+ });
301
+ }
302
+
303
+ private sendAgentAnnounce(conn: A2AConnection): void {
304
+ if (!conn.ws || !conn.connected) return;
305
+
306
+ // Announce ourselves to the agent/bridge
307
+ const announce = {
308
+ type: 'announce',
309
+ agent: {
310
+ id: this.agentId,
311
+ name: this.config.agentName ?? 'Clawdbot',
312
+ description: 'Clawdbot AI assistant',
313
+ skills: ['chat', 'tasks', 'tools'],
314
+ },
315
+ };
316
+ conn.ws.send(JSON.stringify(announce));
317
+ }
318
+
319
+ private scheduleReconnect(agentId: string): void {
320
+ if (this.reconnectTimers.has(agentId)) return;
321
+
322
+ const conn = this.connections.get(agentId);
323
+ if (!conn) return;
324
+
325
+ // Exponential backoff: 5s, 10s, 20s, 40s, max 60s
326
+ const baseDelay = this.config.reconnectIntervalMs ?? 5000;
327
+ const delay = Math.min(baseDelay * Math.pow(2, conn.reconnectAttempts), 60000);
328
+ conn.reconnectAttempts++;
329
+
330
+ console.log(`[a2a] Scheduling reconnect to ${agentId} in ${delay}ms`);
331
+
332
+ const timer = setTimeout(async () => {
333
+ this.reconnectTimers.delete(agentId);
334
+ if (!conn.connected) {
335
+ try {
336
+ // Use GopherHole auth flow for gopherhole connection
337
+ if (agentId === 'gopherhole' && this.config.gopherhole?.apiKey) {
338
+ await this.establishGopherHoleConnection(conn, this.config.gopherhole.apiKey);
339
+ } else {
340
+ await this.establishConnection(conn);
341
+ }
342
+ console.log(`[a2a] Reconnected to ${agentId}`);
343
+ } catch {
344
+ this.scheduleReconnect(agentId);
345
+ }
346
+ }
347
+ }, delay);
348
+
349
+ this.reconnectTimers.set(agentId, timer);
350
+ }
351
+
352
+ private handleMessage(agentId: string, message: A2AMessage): void {
353
+ // Handle responses to our requests
354
+ if (message.type === 'response' || message.status === 'completed' || message.status === 'failed') {
355
+ const pending = this.pendingRequests.get(message.taskId);
356
+ if (pending) {
357
+ clearTimeout(pending.timeout);
358
+ this.pendingRequests.delete(message.taskId);
359
+
360
+ if (message.status === 'failed' || message.error) {
361
+ pending.reject(new Error(message.error ?? 'Task failed'));
362
+ } else {
363
+ const text = message.content?.parts
364
+ ?.filter((p) => p.kind === 'text')
365
+ .map((p) => p.text)
366
+ .join('\n') ?? '';
367
+ pending.resolve({
368
+ text,
369
+ status: message.status ?? 'completed',
370
+ from: message.from,
371
+ });
372
+ }
373
+ return;
374
+ }
375
+ }
376
+
377
+ // Route to message handler for incoming messages
378
+ if (this.messageHandler) {
379
+ this.messageHandler(agentId, message).catch((err) => {
380
+ console.error(`[a2a] Error handling message from ${agentId}:`, err);
381
+ });
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Resolve a GopherHole task response - extract text from artifacts
387
+ */
388
+ private resolveGopherHoleTask(taskId: string, taskData: Record<string, unknown>): void {
389
+ const pending = this.pendingRequests.get(taskId);
390
+ if (!pending) {
391
+ console.log(`[a2a] No pending request for taskId ${taskId}`);
392
+ return;
393
+ }
394
+
395
+ clearTimeout(pending.timeout);
396
+ this.pendingRequests.delete(taskId);
397
+
398
+ const status = taskData.status as { state?: string; message?: string } | undefined;
399
+
400
+ if (status?.state === 'failed') {
401
+ pending.reject(new Error(status.message ?? 'Task failed'));
402
+ return;
403
+ }
404
+
405
+ // Extract text from artifacts (GopherHole puts responses in artifacts, not history!)
406
+ let text = '';
407
+ const artifacts = taskData.artifacts as Array<{
408
+ artifactId?: string;
409
+ parts?: Array<{ kind: string; text?: string }>;
410
+ }> | undefined;
411
+
412
+ if (artifacts?.length) {
413
+ // Get text from all artifacts
414
+ text = artifacts
415
+ .flatMap((artifact) => artifact.parts ?? [])
416
+ .filter((part) => part.kind === 'text' && part.text)
417
+ .map((part) => part.text!)
418
+ .join('\n');
419
+ }
420
+
421
+ console.log(`[a2a] Resolved task ${taskId}: ${text.slice(0, 100)}...`);
422
+ pending.resolve({
423
+ text,
424
+ status: (status?.state as string) ?? 'completed',
425
+ from: 'gopherhole',
426
+ });
427
+ }
428
+
429
+ /**
430
+ * Send a message to another agent and wait for response
431
+ */
432
+ async sendMessage(
433
+ agentId: string,
434
+ text: string,
435
+ contextId?: string
436
+ ): Promise<A2AResponse> {
437
+ const conn = this.connections.get(agentId);
438
+ if (!conn) {
439
+ throw new Error(`Agent ${agentId} not found`);
440
+ }
441
+ if (!conn.connected || !conn.ws) {
442
+ throw new Error(`Agent ${agentId} not connected`);
443
+ }
444
+
445
+ const taskId = uuidv4();
446
+ const timeoutMs = this.config.requestTimeoutMs ?? 60000; // 60s default
447
+
448
+ return new Promise((resolve, reject) => {
449
+ const timeout = setTimeout(() => {
450
+ this.pendingRequests.delete(taskId);
451
+ reject(new Error(`Request timeout after ${timeoutMs / 1000}s - agent may be offline`));
452
+ }, timeoutMs);
453
+
454
+ this.pendingRequests.set(taskId, {
455
+ taskId,
456
+ resolve,
457
+ reject,
458
+ timeout,
459
+ startedAt: Date.now(),
460
+ });
461
+
462
+ const msg: A2AMessage = {
463
+ type: 'message',
464
+ taskId,
465
+ contextId,
466
+ from: this.agentId,
467
+ content: {
468
+ parts: [{ kind: 'text', text }],
469
+ },
470
+ };
471
+
472
+ conn.ws!.send(JSON.stringify(msg));
473
+ });
474
+ }
475
+
476
+ /**
477
+ * Send a response to an incoming message
478
+ */
479
+ sendResponse(agentId: string, taskId: string, text: string, contextId?: string): void {
480
+ const conn = this.connections.get(agentId);
481
+ if (!conn?.ws || !conn.connected) {
482
+ console.warn(`[a2a] Cannot send response - ${agentId} not connected`);
483
+ return;
484
+ }
485
+
486
+ const response: A2AMessage = {
487
+ type: 'response',
488
+ taskId,
489
+ contextId,
490
+ from: this.agentId,
491
+ content: {
492
+ parts: [{ kind: 'text', text }],
493
+ },
494
+ status: 'completed',
495
+ };
496
+
497
+ conn.ws.send(JSON.stringify(response));
498
+ }
499
+
500
+ /**
501
+ * Send a response to an agent via GopherHole (for replying to incoming messages)
502
+ */
503
+ sendResponseViaGopherHole(
504
+ targetAgentId: string,
505
+ taskId: string,
506
+ text: string,
507
+ contextId?: string
508
+ ): void {
509
+ const gphConn = this.connections.get('gopherhole');
510
+ if (!gphConn?.connected || !gphConn.ws) {
511
+ console.warn(`[a2a] Cannot send GopherHole response - not connected`);
512
+ return;
513
+ }
514
+
515
+ // GopherHole task_response format (completes the original task)
516
+ const msg = {
517
+ type: 'task_response',
518
+ taskId,
519
+ to: targetAgentId,
520
+ status: { state: 'completed' },
521
+ artifact: {
522
+ artifactId: `response-${Date.now()}`,
523
+ mimeType: 'text/plain',
524
+ parts: [{ kind: 'text', text }],
525
+ },
526
+ lastChunk: true,
527
+ };
528
+
529
+ console.log(`[a2a] Sending response to ${targetAgentId} via GopherHole: taskId=${taskId}, msg=${JSON.stringify(msg)}`);
530
+ gphConn.ws.send(JSON.stringify(msg));
531
+ }
532
+
533
+ /**
534
+ * Send a message to a remote agent via GopherHole
535
+ * Note: targetAgentId must be the actual agent ID (e.g., "agent-70153299")
536
+ */
537
+ async sendViaGopherHole(
538
+ targetAgentId: string,
539
+ text: string,
540
+ contextId?: string
541
+ ): Promise<A2AResponse> {
542
+ const gphConn = this.connections.get('gopherhole');
543
+ if (!gphConn?.connected || !gphConn.ws) {
544
+ throw new Error('GopherHole not connected');
545
+ }
546
+
547
+ const taskId = uuidv4();
548
+ const timeoutMs = this.config.requestTimeoutMs ?? 60000; // 60s default
549
+
550
+ return new Promise((resolve, reject) => {
551
+ const timeout = setTimeout(() => {
552
+ this.pendingRequests.delete(taskId);
553
+ reject(new Error(`GopherHole request timeout after ${timeoutMs / 1000}s - target agent may be offline`));
554
+ }, timeoutMs);
555
+
556
+ this.pendingRequests.set(taskId, {
557
+ taskId,
558
+ resolve,
559
+ reject,
560
+ timeout,
561
+ startedAt: Date.now(),
562
+ });
563
+
564
+ // GopherHole WebSocket message format
565
+ const msg = {
566
+ type: 'message',
567
+ id: taskId,
568
+ to: targetAgentId,
569
+ payload: {
570
+ parts: [{ kind: 'text', text }],
571
+ ...(contextId ? { contextId } : {}),
572
+ },
573
+ };
574
+
575
+ console.log(`[a2a] Sending to ${targetAgentId} via GopherHole: taskId=${taskId}`);
576
+ gphConn.ws!.send(JSON.stringify(msg));
577
+ });
578
+ }
579
+
580
+ /**
581
+ * Check if GopherHole is connected
582
+ */
583
+ isGopherHoleConnected(): boolean {
584
+ return this.connections.get('gopherhole')?.connected ?? false;
585
+ }
586
+
587
+ /**
588
+ * List connected agents
589
+ */
590
+ listAgents(): Array<{ id: string; name: string; connected: boolean }> {
591
+ return Array.from(this.connections.values()).map((c) => ({
592
+ id: c.id,
593
+ name: c.name,
594
+ connected: c.connected,
595
+ }));
596
+ }
597
+
598
+ /**
599
+ * Check if an agent is connected
600
+ */
601
+ isConnected(agentId: string): boolean {
602
+ const conn = this.connections.get(agentId);
603
+ return conn?.connected ?? false;
604
+ }
605
+ }