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