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.
- package/README.md +67 -0
- package/SKILL.md +84 -0
- package/clawdbot.plugin.json +9 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +82 -0
- package/dist/src/channel.d.ts +68 -0
- package/dist/src/channel.js +271 -0
- package/dist/src/connection.d.ts +62 -0
- package/dist/src/connection.js +513 -0
- package/dist/src/gateway-client.d.ts +18 -0
- package/dist/src/gateway-client.js +256 -0
- package/dist/src/types.d.ts +73 -0
- package/dist/src/types.js +5 -0
- package/index.ts +100 -0
- package/package.json +46 -0
- package/src/channel.ts +377 -0
- package/src/connection.ts +605 -0
- package/src/gateway-client.ts +317 -0
- package/src/types.ts +73 -0
- package/tsconfig.json +17 -0
|
@@ -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
|
+
}
|