@perkos/perkos-a2a 0.8.4 → 0.8.5
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/dist/index.js +1353 -450
- package/dist/index.js.map +7 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +5 -3
package/dist/index.js
CHANGED
|
@@ -1,478 +1,1381 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
import { A2AServer, detectNetworking } from "./server.js";
|
|
1
|
+
import { createRequire as __createRequire } from "module"; const require = __createRequire(import.meta.url);
|
|
2
|
+
|
|
3
|
+
// src/server.ts
|
|
4
|
+
import express from "express";
|
|
5
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
6
|
+
import { homedir, networkInterfaces } from "os";
|
|
7
|
+
|
|
8
|
+
// src/relay-client.ts
|
|
9
|
+
import WebSocket from "ws";
|
|
11
10
|
import { randomUUID } from "crypto";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
11
|
+
var MIN_RECONNECT_MS = 1e3;
|
|
12
|
+
var MAX_RECONNECT_MS = 6e4;
|
|
13
|
+
var HEARTBEAT_INTERVAL_MS = 25e3;
|
|
14
|
+
var RelayClient = class {
|
|
15
|
+
ws = null;
|
|
16
|
+
options;
|
|
17
|
+
logger;
|
|
18
|
+
reconnectMs = MIN_RECONNECT_MS;
|
|
19
|
+
reconnectTimer = null;
|
|
20
|
+
heartbeatTimer = null;
|
|
21
|
+
connected = false;
|
|
22
|
+
stopped = false;
|
|
23
|
+
pendingCallbacks = /* @__PURE__ */ new Map();
|
|
24
|
+
constructor(options) {
|
|
25
|
+
this.options = options;
|
|
26
|
+
this.logger = options.logger || { info: console.log, error: console.error };
|
|
27
|
+
}
|
|
28
|
+
isConnected() {
|
|
29
|
+
return this.connected;
|
|
30
|
+
}
|
|
31
|
+
start() {
|
|
32
|
+
this.stopped = false;
|
|
33
|
+
this.connect();
|
|
34
|
+
}
|
|
35
|
+
stop() {
|
|
36
|
+
this.stopped = true;
|
|
37
|
+
this.clearTimers();
|
|
38
|
+
if (this.ws) {
|
|
39
|
+
this.ws.close(1e3, "Client shutting down");
|
|
40
|
+
this.ws = null;
|
|
41
|
+
}
|
|
42
|
+
this.connected = false;
|
|
43
|
+
}
|
|
44
|
+
/** Send a task to another agent via the relay */
|
|
45
|
+
async sendTask(targetAgent, payload) {
|
|
46
|
+
return this.sendAndWait({
|
|
47
|
+
type: "task",
|
|
48
|
+
to: targetAgent,
|
|
49
|
+
id: randomUUID(),
|
|
50
|
+
from: this.options.agentName,
|
|
51
|
+
payload,
|
|
52
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
53
|
+
}, "task_response", 3e4);
|
|
54
|
+
}
|
|
55
|
+
/** Discover agents connected to the relay */
|
|
56
|
+
async discover() {
|
|
57
|
+
const response = await this.sendAndWait({
|
|
58
|
+
type: "discover",
|
|
59
|
+
id: randomUUID(),
|
|
60
|
+
from: this.options.agentName,
|
|
61
|
+
payload: {},
|
|
62
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
63
|
+
}, "discover_response", 1e4);
|
|
64
|
+
return response.payload.agents || [];
|
|
65
|
+
}
|
|
66
|
+
/** Send a task response back through the relay */
|
|
67
|
+
sendTaskResponse(originalMsg, result) {
|
|
68
|
+
const response = {
|
|
69
|
+
type: "task_response",
|
|
70
|
+
id: originalMsg.id,
|
|
71
|
+
from: this.options.agentName,
|
|
72
|
+
to: originalMsg.from,
|
|
73
|
+
payload: result,
|
|
74
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
75
|
+
};
|
|
76
|
+
this.send(response);
|
|
77
|
+
}
|
|
78
|
+
connect() {
|
|
79
|
+
if (this.stopped) return;
|
|
80
|
+
const url = this.options.relay.url;
|
|
81
|
+
this.logger.info(`[perkos-a2a] Connecting to relay: ${url}`);
|
|
82
|
+
try {
|
|
83
|
+
this.ws = new WebSocket(url);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
86
|
+
this.logger.error(`[perkos-a2a] Failed to create WebSocket: ${msg}`);
|
|
87
|
+
this.scheduleReconnect();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
this.ws.on("open", () => {
|
|
91
|
+
this.connected = true;
|
|
92
|
+
this.reconnectMs = MIN_RECONNECT_MS;
|
|
93
|
+
this.logger.info("[perkos-a2a] Connected to relay hub");
|
|
94
|
+
this.register();
|
|
95
|
+
this.startHeartbeat();
|
|
96
|
+
});
|
|
97
|
+
this.ws.on("message", (data) => {
|
|
98
|
+
let msg;
|
|
99
|
+
try {
|
|
100
|
+
msg = JSON.parse(data.toString());
|
|
101
|
+
} catch {
|
|
102
|
+
this.logger.error("[perkos-a2a] Failed to parse relay message");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
this.handleMessage(msg);
|
|
106
|
+
});
|
|
107
|
+
this.ws.on("close", (code, reason) => {
|
|
108
|
+
this.connected = false;
|
|
109
|
+
this.clearTimers();
|
|
110
|
+
if (!this.stopped) {
|
|
111
|
+
this.logger.info(`[perkos-a2a] Relay connection closed (${code}: ${reason || "no reason"}). Reconnecting...`);
|
|
112
|
+
this.scheduleReconnect();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
this.ws.on("error", (err) => {
|
|
116
|
+
this.logger.error(`[perkos-a2a] Relay WebSocket error: ${err.message}`);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
register() {
|
|
120
|
+
const msg = {
|
|
121
|
+
type: "register",
|
|
122
|
+
id: randomUUID(),
|
|
123
|
+
from: this.options.agentName,
|
|
124
|
+
payload: {
|
|
125
|
+
agentName: this.options.agentName,
|
|
126
|
+
apiKey: this.options.relay.apiKey,
|
|
127
|
+
card: this.options.card
|
|
128
|
+
},
|
|
129
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
130
|
+
};
|
|
131
|
+
this.send(msg);
|
|
132
|
+
}
|
|
133
|
+
handleMessage(msg) {
|
|
134
|
+
const callback = this.pendingCallbacks.get(msg.id);
|
|
135
|
+
if (callback) {
|
|
136
|
+
this.pendingCallbacks.delete(msg.id);
|
|
137
|
+
callback(msg);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
switch (msg.type) {
|
|
141
|
+
case "register_ack":
|
|
142
|
+
this.logger.info("[perkos-a2a] Registered with relay hub");
|
|
143
|
+
break;
|
|
144
|
+
case "task":
|
|
145
|
+
this.options.onTask(msg);
|
|
146
|
+
break;
|
|
147
|
+
case "task_response":
|
|
148
|
+
this.logger.info(`[perkos-a2a] Received unmatched task response: ${msg.id}`);
|
|
149
|
+
break;
|
|
150
|
+
case "discover_response":
|
|
151
|
+
if (this.options.onDiscoverResponse) {
|
|
152
|
+
this.options.onDiscoverResponse(msg.payload.agents);
|
|
22
153
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
154
|
+
break;
|
|
155
|
+
case "heartbeat_ack":
|
|
156
|
+
break;
|
|
157
|
+
case "error":
|
|
158
|
+
this.logger.error(`[perkos-a2a] Relay error: ${msg.payload.code} - ${msg.payload.message}`);
|
|
159
|
+
break;
|
|
160
|
+
default:
|
|
161
|
+
this.logger.info(`[perkos-a2a] Unknown relay message type: ${msg.type}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
send(msg) {
|
|
165
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
166
|
+
this.ws.send(JSON.stringify(msg));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async sendAndWait(msg, expectedType, timeoutMs) {
|
|
170
|
+
return new Promise((resolve, reject) => {
|
|
171
|
+
const timer = setTimeout(() => {
|
|
172
|
+
this.pendingCallbacks.delete(msg.id);
|
|
173
|
+
reject(new Error(`Relay request timed out after ${timeoutMs}ms`));
|
|
174
|
+
}, timeoutMs);
|
|
175
|
+
this.pendingCallbacks.set(msg.id, (response) => {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
if (response.type === "error") {
|
|
178
|
+
reject(new Error(`Relay error: ${response.payload.message}`));
|
|
179
|
+
} else {
|
|
180
|
+
resolve(response);
|
|
26
181
|
}
|
|
182
|
+
});
|
|
183
|
+
this.send(msg);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
startHeartbeat() {
|
|
187
|
+
this.heartbeatTimer = setInterval(() => {
|
|
188
|
+
const msg = {
|
|
189
|
+
type: "heartbeat",
|
|
190
|
+
id: randomUUID(),
|
|
191
|
+
from: this.options.agentName,
|
|
192
|
+
payload: {},
|
|
193
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
194
|
+
};
|
|
195
|
+
this.send(msg);
|
|
196
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
197
|
+
}
|
|
198
|
+
scheduleReconnect() {
|
|
199
|
+
if (this.stopped) return;
|
|
200
|
+
this.reconnectTimer = setTimeout(() => {
|
|
201
|
+
this.connect();
|
|
202
|
+
}, this.reconnectMs);
|
|
203
|
+
this.reconnectMs = Math.min(this.reconnectMs * 2, MAX_RECONNECT_MS);
|
|
204
|
+
}
|
|
205
|
+
clearTimers() {
|
|
206
|
+
if (this.heartbeatTimer) {
|
|
207
|
+
clearInterval(this.heartbeatTimer);
|
|
208
|
+
this.heartbeatTimer = null;
|
|
27
209
|
}
|
|
28
|
-
|
|
29
|
-
|
|
210
|
+
if (this.reconnectTimer) {
|
|
211
|
+
clearTimeout(this.reconnectTimer);
|
|
212
|
+
this.reconnectTimer = null;
|
|
30
213
|
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// src/relay.ts
|
|
218
|
+
import { WebSocketServer, WebSocket as WebSocket2 } from "ws";
|
|
219
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
220
|
+
var DEFAULT_CONFIG = {
|
|
221
|
+
port: 6060,
|
|
222
|
+
apiKeys: [],
|
|
223
|
+
maxQueuePerAgent: 200,
|
|
224
|
+
rateLimitPerMinute: 60,
|
|
225
|
+
heartbeatIntervalMs: 3e4,
|
|
226
|
+
heartbeatTimeoutMs: 9e4
|
|
227
|
+
};
|
|
228
|
+
var RelayHub = class {
|
|
229
|
+
wss = null;
|
|
230
|
+
agents = /* @__PURE__ */ new Map();
|
|
231
|
+
offlineQueue = /* @__PURE__ */ new Map();
|
|
232
|
+
rateCounts = /* @__PURE__ */ new Map();
|
|
233
|
+
config;
|
|
234
|
+
heartbeatTimer = null;
|
|
235
|
+
logger;
|
|
236
|
+
constructor(config, logger) {
|
|
237
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
238
|
+
this.logger = logger || { info: console.log, error: console.error };
|
|
239
|
+
}
|
|
240
|
+
start() {
|
|
241
|
+
this.wss = new WebSocketServer({ port: this.config.port });
|
|
242
|
+
this.logger.info(`[perkos-a2a] Relay hub listening on port ${this.config.port}`);
|
|
243
|
+
this.wss.on("connection", (ws, req) => {
|
|
244
|
+
const remoteAddr = req.socket.remoteAddress || "unknown";
|
|
245
|
+
this.logger.info(`[perkos-a2a] Relay connection from ${remoteAddr}`);
|
|
246
|
+
this.handleConnection(ws);
|
|
247
|
+
});
|
|
248
|
+
this.heartbeatTimer = setInterval(() => this.checkHeartbeats(), this.config.heartbeatIntervalMs);
|
|
249
|
+
}
|
|
250
|
+
stop() {
|
|
251
|
+
if (this.heartbeatTimer) {
|
|
252
|
+
clearInterval(this.heartbeatTimer);
|
|
253
|
+
this.heartbeatTimer = null;
|
|
254
|
+
}
|
|
255
|
+
if (this.wss) {
|
|
256
|
+
for (const agent of this.agents.values()) {
|
|
257
|
+
agent.ws.close(1001, "Relay shutting down");
|
|
258
|
+
}
|
|
259
|
+
this.wss.close();
|
|
260
|
+
this.wss = null;
|
|
261
|
+
this.logger.info("[perkos-a2a] Relay hub stopped");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
getRegistry() {
|
|
265
|
+
const entries = [];
|
|
266
|
+
for (const agent of this.agents.values()) {
|
|
267
|
+
entries.push({
|
|
268
|
+
name: agent.name,
|
|
269
|
+
connectedAt: agent.connectedAt,
|
|
270
|
+
lastHeartbeat: agent.lastHeartbeat,
|
|
271
|
+
card: agent.card
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return entries;
|
|
275
|
+
}
|
|
276
|
+
handleConnection(ws) {
|
|
277
|
+
let agentName = null;
|
|
278
|
+
ws.on("message", (data) => {
|
|
279
|
+
let msg;
|
|
280
|
+
try {
|
|
281
|
+
msg = JSON.parse(data.toString());
|
|
282
|
+
} catch {
|
|
283
|
+
this.sendError(ws, "invalid_json", "Failed to parse message");
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (msg.type === "register") {
|
|
287
|
+
agentName = this.handleRegister(ws, msg);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (!agentName) {
|
|
291
|
+
this.sendError(ws, "not_registered", "Must register before sending messages");
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if (!this.checkRateLimit(agentName)) {
|
|
295
|
+
this.sendError(ws, "rate_limited", "Rate limit exceeded");
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
switch (msg.type) {
|
|
299
|
+
case "task":
|
|
300
|
+
case "task_response":
|
|
301
|
+
this.routeMessage(agentName, msg);
|
|
302
|
+
break;
|
|
303
|
+
case "discover":
|
|
304
|
+
this.handleDiscover(ws, msg);
|
|
305
|
+
break;
|
|
306
|
+
case "heartbeat":
|
|
307
|
+
this.handleHeartbeat(agentName, msg);
|
|
308
|
+
break;
|
|
309
|
+
default:
|
|
310
|
+
this.sendError(ws, "unknown_type", `Unknown message type: ${msg.type}`);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
ws.on("close", () => {
|
|
314
|
+
if (agentName) {
|
|
315
|
+
this.agents.delete(agentName);
|
|
316
|
+
this.logger.info(`[perkos-a2a] Agent disconnected: ${agentName}`);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
ws.on("error", (err) => {
|
|
320
|
+
this.logger.error(`[perkos-a2a] WebSocket error for ${agentName || "unknown"}: ${err.message}`);
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
handleRegister(ws, msg) {
|
|
324
|
+
const name = msg.payload.agentName;
|
|
325
|
+
const apiKey = msg.payload.apiKey;
|
|
326
|
+
if (!name) {
|
|
327
|
+
this.sendError(ws, "missing_name", "agentName is required for registration");
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
if (this.config.apiKeys.length > 0 && !this.config.apiKeys.includes(apiKey)) {
|
|
331
|
+
this.sendError(ws, "auth_failed", "Invalid API key");
|
|
332
|
+
ws.close(4001, "Authentication failed");
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
const existing = this.agents.get(name);
|
|
336
|
+
if (existing) {
|
|
337
|
+
existing.ws.close(4002, "Replaced by new connection");
|
|
338
|
+
}
|
|
339
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
340
|
+
this.agents.set(name, {
|
|
341
|
+
name,
|
|
342
|
+
ws,
|
|
343
|
+
apiKey: apiKey || "",
|
|
344
|
+
card: msg.payload.card,
|
|
345
|
+
connectedAt: now,
|
|
346
|
+
lastHeartbeat: now
|
|
347
|
+
});
|
|
348
|
+
this.logger.info(`[perkos-a2a] Agent registered: ${name}`);
|
|
349
|
+
const ack = {
|
|
350
|
+
type: "register_ack",
|
|
351
|
+
id: msg.id,
|
|
352
|
+
from: "relay",
|
|
353
|
+
payload: { status: "ok", agentName: name },
|
|
354
|
+
timestamp: now
|
|
38
355
|
};
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
356
|
+
ws.send(JSON.stringify(ack));
|
|
357
|
+
this.drainQueue(name, ws);
|
|
358
|
+
return name;
|
|
359
|
+
}
|
|
360
|
+
routeMessage(fromAgent, msg) {
|
|
361
|
+
const target = msg.to;
|
|
362
|
+
if (!target) {
|
|
363
|
+
this.sendError(this.agents.get(fromAgent).ws, "missing_target", "Message requires 'to' field");
|
|
364
|
+
return;
|
|
48
365
|
}
|
|
49
|
-
|
|
50
|
-
|
|
366
|
+
msg.from = fromAgent;
|
|
367
|
+
msg.timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
368
|
+
const targetAgent = this.agents.get(target);
|
|
369
|
+
if (targetAgent && targetAgent.ws.readyState === WebSocket2.OPEN) {
|
|
370
|
+
targetAgent.ws.send(JSON.stringify(msg));
|
|
371
|
+
} else {
|
|
372
|
+
if (!this.offlineQueue.has(target)) {
|
|
373
|
+
this.offlineQueue.set(target, []);
|
|
374
|
+
}
|
|
375
|
+
const queue = this.offlineQueue.get(target);
|
|
376
|
+
if (queue.length < this.config.maxQueuePerAgent) {
|
|
377
|
+
queue.push({ message: msg, queuedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
378
|
+
this.logger.info(`[perkos-a2a] Queued message for offline agent: ${target} (${queue.length} pending)`);
|
|
379
|
+
} else {
|
|
380
|
+
this.logger.info(`[perkos-a2a] Queue full for agent: ${target}, dropping message`);
|
|
381
|
+
const sender = this.agents.get(fromAgent);
|
|
382
|
+
if (sender) {
|
|
383
|
+
this.sendError(sender.ws, "queue_full", `Target agent ${target} queue is full`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
51
386
|
}
|
|
52
|
-
|
|
53
|
-
|
|
387
|
+
}
|
|
388
|
+
drainQueue(agentName, ws) {
|
|
389
|
+
const queue = this.offlineQueue.get(agentName);
|
|
390
|
+
if (!queue || queue.length === 0) return;
|
|
391
|
+
this.logger.info(`[perkos-a2a] Delivering ${queue.length} queued messages to ${agentName}`);
|
|
392
|
+
for (const item of queue) {
|
|
393
|
+
if (ws.readyState === WebSocket2.OPEN) {
|
|
394
|
+
ws.send(JSON.stringify(item.message));
|
|
395
|
+
}
|
|
54
396
|
}
|
|
55
|
-
|
|
56
|
-
|
|
397
|
+
this.offlineQueue.delete(agentName);
|
|
398
|
+
}
|
|
399
|
+
handleDiscover(ws, msg) {
|
|
400
|
+
const agents = this.getRegistry();
|
|
401
|
+
const response = {
|
|
402
|
+
type: "discover_response",
|
|
403
|
+
id: msg.id,
|
|
404
|
+
from: "relay",
|
|
405
|
+
payload: { agents },
|
|
406
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
407
|
+
};
|
|
408
|
+
ws.send(JSON.stringify(response));
|
|
409
|
+
}
|
|
410
|
+
handleHeartbeat(agentName, msg) {
|
|
411
|
+
const agent = this.agents.get(agentName);
|
|
412
|
+
if (agent) {
|
|
413
|
+
agent.lastHeartbeat = (/* @__PURE__ */ new Date()).toISOString();
|
|
57
414
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
415
|
+
const ack = {
|
|
416
|
+
type: "heartbeat_ack",
|
|
417
|
+
id: msg.id,
|
|
418
|
+
from: "relay",
|
|
419
|
+
payload: {},
|
|
420
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
421
|
+
};
|
|
422
|
+
const ws = agent?.ws;
|
|
423
|
+
if (ws && ws.readyState === WebSocket2.OPEN) {
|
|
424
|
+
ws.send(JSON.stringify(ack));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
checkHeartbeats() {
|
|
428
|
+
const now = Date.now();
|
|
429
|
+
for (const [name, agent] of this.agents) {
|
|
430
|
+
const lastBeat = new Date(agent.lastHeartbeat).getTime();
|
|
431
|
+
if (now - lastBeat > this.config.heartbeatTimeoutMs) {
|
|
432
|
+
this.logger.info(`[perkos-a2a] Agent ${name} timed out (no heartbeat)`);
|
|
433
|
+
agent.ws.close(4003, "Heartbeat timeout");
|
|
434
|
+
this.agents.delete(name);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
checkRateLimit(agentName) {
|
|
439
|
+
const now = Date.now();
|
|
440
|
+
const window = 6e4;
|
|
441
|
+
let entry = this.rateCounts.get(agentName);
|
|
442
|
+
if (!entry || now - entry.windowStart > window) {
|
|
443
|
+
entry = { count: 0, windowStart: now };
|
|
444
|
+
this.rateCounts.set(agentName, entry);
|
|
445
|
+
}
|
|
446
|
+
entry.count++;
|
|
447
|
+
return entry.count <= this.config.rateLimitPerMinute;
|
|
448
|
+
}
|
|
449
|
+
sendError(ws, code, message) {
|
|
450
|
+
const errorMsg = {
|
|
451
|
+
type: "error",
|
|
452
|
+
id: randomUUID2(),
|
|
453
|
+
from: "relay",
|
|
454
|
+
payload: { code, message },
|
|
455
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
456
|
+
};
|
|
457
|
+
if (ws.readyState === WebSocket2.OPEN) {
|
|
458
|
+
ws.send(JSON.stringify(errorMsg));
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
// src/server.ts
|
|
464
|
+
async function detectNetworking() {
|
|
465
|
+
const localIps = [];
|
|
466
|
+
const ifaces = networkInterfaces();
|
|
467
|
+
for (const addrs of Object.values(ifaces)) {
|
|
468
|
+
if (!addrs) continue;
|
|
469
|
+
for (const a of addrs) {
|
|
470
|
+
if (!a.internal && a.family === "IPv4") {
|
|
471
|
+
localIps.push(a.address);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
let publicIp = null;
|
|
476
|
+
try {
|
|
477
|
+
const res = await fetch("https://api.ipify.org?format=text", {
|
|
478
|
+
signal: AbortSignal.timeout(5e3)
|
|
479
|
+
});
|
|
480
|
+
publicIp = (await res.text()).trim();
|
|
481
|
+
} catch {
|
|
482
|
+
}
|
|
483
|
+
const isBehindNat = publicIp !== null && !localIps.includes(publicIp);
|
|
484
|
+
const hasTailscale = false;
|
|
485
|
+
const tailscaleIp = null;
|
|
486
|
+
return { isBehindNat, publicIp, localIps, hasTailscale, tailscaleIp };
|
|
487
|
+
}
|
|
488
|
+
var A2AServer = class {
|
|
489
|
+
app;
|
|
490
|
+
tasks = /* @__PURE__ */ new Map();
|
|
491
|
+
agentCard;
|
|
492
|
+
config;
|
|
493
|
+
logger;
|
|
494
|
+
clientOnly = false;
|
|
495
|
+
relayClient = null;
|
|
496
|
+
relayHub = null;
|
|
497
|
+
messageInjector = null;
|
|
498
|
+
taskResultHandler = null;
|
|
499
|
+
taskFailureHandler = null;
|
|
500
|
+
constructor(config, logger) {
|
|
501
|
+
this.config = config;
|
|
502
|
+
this.logger = logger || { info: console.log, error: console.error };
|
|
503
|
+
this.app = express();
|
|
504
|
+
this.app.use(express.json());
|
|
505
|
+
this.agentCard = {
|
|
506
|
+
name: config.agentName,
|
|
507
|
+
description: `PerkOS agent: ${config.agentName}`,
|
|
508
|
+
protocolVersion: "0.3.0",
|
|
509
|
+
version: "1.0.0",
|
|
510
|
+
url: `http://localhost:${config.port}/a2a/jsonrpc`,
|
|
511
|
+
skills: config.skills || [],
|
|
512
|
+
capabilities: { pushNotifications: false },
|
|
513
|
+
defaultInputModes: ["text"],
|
|
514
|
+
defaultOutputModes: ["text"]
|
|
515
|
+
};
|
|
516
|
+
this.setupRoutes();
|
|
517
|
+
}
|
|
518
|
+
/** Set the message injector for delivering tasks into the agent session */
|
|
519
|
+
setMessageInjector(injector) {
|
|
520
|
+
this.messageInjector = injector;
|
|
521
|
+
}
|
|
522
|
+
setTaskResultHandler(handler) {
|
|
523
|
+
this.taskResultHandler = handler;
|
|
524
|
+
}
|
|
525
|
+
setTaskFailureHandler(handler) {
|
|
526
|
+
this.taskFailureHandler = handler;
|
|
527
|
+
}
|
|
528
|
+
isClientOnly() {
|
|
529
|
+
return this.clientOnly;
|
|
530
|
+
}
|
|
531
|
+
isRelayConnected() {
|
|
532
|
+
return this.relayClient?.isConnected() || false;
|
|
533
|
+
}
|
|
534
|
+
authMiddleware() {
|
|
535
|
+
return (req, res, next) => {
|
|
536
|
+
if (!this.config.auth?.requireApiKey) {
|
|
537
|
+
next();
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const apiKey = req.headers["x-api-key"] || req.headers["authorization"]?.replace(/^Bearer\s+/i, "") || req.query["apiKey"];
|
|
541
|
+
if (!apiKey || !this.config.auth.apiKeys.includes(apiKey)) {
|
|
542
|
+
res.status(401).json({ error: "Unauthorized: invalid or missing API key" });
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
next();
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
setupRoutes() {
|
|
549
|
+
this.app.get("/.well-known/agent-card.json", (_req, res) => {
|
|
550
|
+
res.json(this.agentCard);
|
|
551
|
+
});
|
|
552
|
+
this.app.get("/health", (_req, res) => {
|
|
553
|
+
res.json({
|
|
554
|
+
ok: true,
|
|
555
|
+
agent: this.config.agentName,
|
|
556
|
+
protocol: "a2a",
|
|
557
|
+
version: "0.8.1",
|
|
558
|
+
peers: Object.keys(this.config.peers),
|
|
559
|
+
taskCount: this.tasks.size,
|
|
560
|
+
relayConnected: this.isRelayConnected()
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
const auth = this.authMiddleware();
|
|
564
|
+
this.app.post("/a2a/jsonrpc", auth, async (req, res) => {
|
|
565
|
+
const { method, params, id } = req.body;
|
|
566
|
+
try {
|
|
567
|
+
let result;
|
|
568
|
+
switch (method) {
|
|
569
|
+
case "message/send":
|
|
570
|
+
result = await this.handleSendMessage(params, id);
|
|
571
|
+
break;
|
|
572
|
+
case "tasks/get":
|
|
573
|
+
result = this.handleGetTask(params, id);
|
|
574
|
+
break;
|
|
575
|
+
case "tasks/list":
|
|
576
|
+
result = this.handleListTasks(id);
|
|
577
|
+
break;
|
|
578
|
+
case "tasks/cancel":
|
|
579
|
+
result = this.handleCancelTask(params, id);
|
|
580
|
+
break;
|
|
581
|
+
case "agent/card":
|
|
582
|
+
result = this.success(id, this.agentCard);
|
|
583
|
+
break;
|
|
584
|
+
default:
|
|
585
|
+
result = this.error(id, -32601, `Method not found: ${method}`);
|
|
66
586
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
587
|
+
res.json(result);
|
|
588
|
+
} catch (err) {
|
|
589
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
590
|
+
this.logger.error(`[perkos-a2a] RPC error: ${msg}`);
|
|
591
|
+
res.json(this.error(id, -32603, msg));
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
this.app.get("/a2a/peers", auth, async (_req, res) => {
|
|
595
|
+
const results = await this.discoverPeers();
|
|
596
|
+
res.json(results);
|
|
597
|
+
});
|
|
598
|
+
this.app.post("/a2a/send", auth, async (req, res) => {
|
|
599
|
+
const { target, message } = req.body;
|
|
600
|
+
try {
|
|
601
|
+
const result = await this.sendTask(target, message);
|
|
602
|
+
res.json({ ok: true, result });
|
|
603
|
+
} catch (err) {
|
|
604
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
605
|
+
res.status(500).json({ ok: false, error: msg });
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
async handleSendMessage(params, rpcId) {
|
|
610
|
+
const message = params.message;
|
|
611
|
+
const taskId = randomUUID3();
|
|
612
|
+
const contextId = message?.contextId || randomUUID3();
|
|
613
|
+
const task = {
|
|
614
|
+
kind: "task",
|
|
615
|
+
id: taskId,
|
|
616
|
+
contextId,
|
|
617
|
+
status: { state: "submitted", timestamp: (/* @__PURE__ */ new Date()).toISOString() },
|
|
618
|
+
messages: [message],
|
|
619
|
+
artifacts: [],
|
|
620
|
+
metadata: {
|
|
621
|
+
fromAgent: message?.metadata?.fromAgent || "unknown"
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
this.tasks.set(taskId, task);
|
|
625
|
+
this.logger.info(
|
|
626
|
+
`[perkos-a2a] Task ${taskId} received from ${task.metadata?.fromAgent}`
|
|
627
|
+
);
|
|
628
|
+
this.processTask(task);
|
|
629
|
+
return this.success(rpcId, task);
|
|
630
|
+
}
|
|
631
|
+
async processTask(task) {
|
|
632
|
+
task.status = { state: "working", timestamp: (/* @__PURE__ */ new Date()).toISOString() };
|
|
633
|
+
const textParts = task.messages.flatMap((m) => m.parts || []).filter((p) => p.kind === "text").map((p) => p.text).join("\n");
|
|
634
|
+
try {
|
|
635
|
+
if (this.messageInjector) {
|
|
636
|
+
this.messageInjector(textParts, {
|
|
637
|
+
source: "a2a",
|
|
638
|
+
fromAgent: task.metadata?.fromAgent,
|
|
639
|
+
taskId: task.id
|
|
89
640
|
});
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
state: "completed",
|
|
101
|
-
timestamp: new Date().toISOString(),
|
|
102
|
-
message: {
|
|
103
|
-
role: "agent",
|
|
104
|
-
parts: [{ kind: "text", text: finalText }],
|
|
105
|
-
},
|
|
106
|
-
};
|
|
107
|
-
logger.info(`[perkos-a2a] Task ${task.id} final result captured from embedded agent`);
|
|
108
|
-
}
|
|
109
|
-
else {
|
|
110
|
-
task.status = {
|
|
111
|
-
state: "completed",
|
|
112
|
-
timestamp: new Date().toISOString(),
|
|
113
|
-
message: {
|
|
114
|
-
role: "agent",
|
|
115
|
-
parts: [{ kind: "text", text: "Task executed but no final visible text was captured." }],
|
|
116
|
-
},
|
|
117
|
-
};
|
|
118
|
-
logger.info(`[perkos-a2a] Task ${task.id} executed, but no final visible text was captured`);
|
|
641
|
+
task.artifacts.push({
|
|
642
|
+
kind: "artifact",
|
|
643
|
+
artifactId: randomUUID3(),
|
|
644
|
+
parts: [{ kind: "text", text: "Task injected into agent session" }]
|
|
645
|
+
});
|
|
646
|
+
} else {
|
|
647
|
+
const fs = await import("fs");
|
|
648
|
+
const taskDir = this.config.workspacePath || `${homedir()}/.openclaw/workspace/memory`;
|
|
649
|
+
if (!fs.existsSync(taskDir)) {
|
|
650
|
+
fs.mkdirSync(taskDir, { recursive: true });
|
|
119
651
|
}
|
|
120
|
-
|
|
121
|
-
|
|
652
|
+
const taskFile = `${taskDir}/a2a-task-${task.id}.md`;
|
|
653
|
+
const content = [
|
|
654
|
+
"# A2A Task",
|
|
655
|
+
"",
|
|
656
|
+
`**From:** ${task.metadata?.fromAgent}`,
|
|
657
|
+
`**Task ID:** ${task.id}`,
|
|
658
|
+
`**Time:** ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
659
|
+
"",
|
|
660
|
+
"## Message",
|
|
661
|
+
"",
|
|
662
|
+
textParts,
|
|
663
|
+
""
|
|
664
|
+
].join("\n");
|
|
665
|
+
fs.writeFileSync(taskFile, content);
|
|
666
|
+
task.artifacts.push({
|
|
667
|
+
kind: "artifact",
|
|
668
|
+
artifactId: randomUUID3(),
|
|
669
|
+
parts: [{ kind: "text", text: `Task queued: ${taskFile}` }]
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
if (this.taskResultHandler) {
|
|
673
|
+
await this.taskResultHandler(task, textParts);
|
|
674
|
+
} else {
|
|
122
675
|
task.status = {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
message: {
|
|
126
|
-
role: "agent",
|
|
127
|
-
parts: [{ kind: "text", text: errorText }],
|
|
128
|
-
},
|
|
676
|
+
state: "completed",
|
|
677
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
129
678
|
};
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
679
|
+
}
|
|
680
|
+
this.logger.info(`[perkos-a2a] Task ${task.id} completed`);
|
|
681
|
+
} catch (err) {
|
|
682
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
683
|
+
if (this.taskFailureHandler) {
|
|
684
|
+
await this.taskFailureHandler(task, msg);
|
|
685
|
+
} else {
|
|
686
|
+
task.status = {
|
|
687
|
+
state: "failed",
|
|
688
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
689
|
+
message: {
|
|
690
|
+
role: "agent",
|
|
691
|
+
parts: [{ kind: "text", text: msg }]
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
this.logger.error(`[perkos-a2a] Task ${task.id} failed: ${msg}`);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
handleGetTask(params, rpcId) {
|
|
699
|
+
const task = this.tasks.get(params?.id);
|
|
700
|
+
if (!task) return this.error(rpcId, 404, "Task not found");
|
|
701
|
+
return this.success(rpcId, task);
|
|
702
|
+
}
|
|
703
|
+
handleListTasks(rpcId) {
|
|
704
|
+
const allTasks = Array.from(this.tasks.values()).sort(
|
|
705
|
+
(a, b) => new Date(b.status.timestamp).getTime() - new Date(a.status.timestamp).getTime()
|
|
706
|
+
);
|
|
707
|
+
return this.success(rpcId, { tasks: allTasks, nextPageToken: "" });
|
|
708
|
+
}
|
|
709
|
+
handleCancelTask(params, rpcId) {
|
|
710
|
+
const task = this.tasks.get(params?.id);
|
|
711
|
+
if (!task) return this.error(rpcId, 404, "Task not found");
|
|
712
|
+
if (["completed", "failed", "canceled"].includes(task.status.state)) {
|
|
713
|
+
return this.error(rpcId, 409, "Task not cancelable");
|
|
714
|
+
}
|
|
715
|
+
task.status = { state: "canceled", timestamp: (/* @__PURE__ */ new Date()).toISOString() };
|
|
716
|
+
return this.success(rpcId, task);
|
|
717
|
+
}
|
|
718
|
+
/** Send a task to a peer agent via A2A protocol (direct HTTP or relay) */
|
|
719
|
+
async sendTask(targetAgent, messageText) {
|
|
720
|
+
const targetUrl = this.config.peers[targetAgent];
|
|
721
|
+
if (targetUrl) {
|
|
722
|
+
try {
|
|
723
|
+
return await this.sendTaskDirect(targetAgent, targetUrl, messageText);
|
|
724
|
+
} catch (err) {
|
|
725
|
+
if (!this.relayClient?.isConnected()) {
|
|
726
|
+
throw err;
|
|
157
727
|
}
|
|
158
|
-
|
|
159
|
-
|
|
728
|
+
this.logger.info(
|
|
729
|
+
`[perkos-a2a] Direct send to ${targetAgent} failed, falling back to relay`
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
if (this.relayClient?.isConnected()) {
|
|
734
|
+
return this.sendTaskViaRelay(targetAgent, messageText);
|
|
735
|
+
}
|
|
736
|
+
throw new Error(
|
|
737
|
+
`Cannot reach ${targetAgent}: no direct URL configured and relay not connected. Known peers: ${Object.keys(this.config.peers).join(", ")}`
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
async sendTaskDirect(targetAgent, targetUrl, messageText) {
|
|
741
|
+
const headers = { "Content-Type": "application/json" };
|
|
742
|
+
const peerAuth = this.config.peerAuth?.[targetAgent];
|
|
743
|
+
if (peerAuth) {
|
|
744
|
+
headers["x-api-key"] = peerAuth;
|
|
745
|
+
}
|
|
746
|
+
const payload = {
|
|
747
|
+
jsonrpc: "2.0",
|
|
748
|
+
method: "message/send",
|
|
749
|
+
id: randomUUID3(),
|
|
750
|
+
params: {
|
|
751
|
+
message: {
|
|
752
|
+
kind: "message",
|
|
753
|
+
messageId: randomUUID3(),
|
|
754
|
+
role: "user",
|
|
755
|
+
parts: [{ kind: "text", text: messageText }],
|
|
756
|
+
metadata: { fromAgent: this.config.agentName }
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
const response = await fetch(`${targetUrl}/a2a/jsonrpc`, {
|
|
761
|
+
method: "POST",
|
|
762
|
+
headers,
|
|
763
|
+
body: JSON.stringify(payload)
|
|
160
764
|
});
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
765
|
+
return await response.json();
|
|
766
|
+
}
|
|
767
|
+
async sendTaskViaRelay(targetAgent, messageText) {
|
|
768
|
+
const rpcId = randomUUID3();
|
|
769
|
+
const result = await this.relayClient.sendTask(targetAgent, {
|
|
770
|
+
jsonrpc: "2.0",
|
|
771
|
+
method: "message/send",
|
|
772
|
+
id: rpcId,
|
|
773
|
+
params: {
|
|
774
|
+
message: {
|
|
775
|
+
kind: "message",
|
|
776
|
+
messageId: randomUUID3(),
|
|
777
|
+
role: "user",
|
|
778
|
+
parts: [{ kind: "text", text: messageText }],
|
|
779
|
+
metadata: { fromAgent: this.config.agentName }
|
|
174
780
|
}
|
|
175
|
-
|
|
176
|
-
logger.info(`[perkos-a2a] Injecting ${tasks.length} task(s) into agent context via before_agent_start`);
|
|
177
|
-
return { prependContext: lines.join("\n") };
|
|
781
|
+
}
|
|
178
782
|
});
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
783
|
+
return result.payload || this.success(rpcId, result.payload);
|
|
784
|
+
}
|
|
785
|
+
/** Discover all peer agents (direct + relay) */
|
|
786
|
+
async discoverPeers() {
|
|
787
|
+
const results = {};
|
|
788
|
+
for (const [name, url] of Object.entries(this.config.peers)) {
|
|
789
|
+
try {
|
|
790
|
+
const r = await fetch(`${url}/.well-known/agent-card.json`, {
|
|
791
|
+
signal: AbortSignal.timeout(3e3)
|
|
792
|
+
});
|
|
793
|
+
results[name] = { status: "online", card: await r.json() };
|
|
794
|
+
} catch {
|
|
795
|
+
results[name] = { status: "offline" };
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
if (this.relayClient?.isConnected()) {
|
|
799
|
+
try {
|
|
800
|
+
const relayAgents = await this.relayClient.discover();
|
|
801
|
+
for (const agent of relayAgents) {
|
|
802
|
+
if (agent.name === this.config.agentName) continue;
|
|
803
|
+
const existing = results[agent.name];
|
|
804
|
+
if (!existing || existing.status === "offline") {
|
|
805
|
+
results[agent.name] = {
|
|
806
|
+
status: "online",
|
|
807
|
+
card: agent.card
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
} catch (err) {
|
|
812
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
813
|
+
this.logger.error(`[perkos-a2a] Relay discovery failed: ${msg}`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
return results;
|
|
817
|
+
}
|
|
818
|
+
success(id, result) {
|
|
819
|
+
return { jsonrpc: "2.0", id, result };
|
|
820
|
+
}
|
|
821
|
+
error(id, code, message) {
|
|
822
|
+
return { jsonrpc: "2.0", id, error: { code, message } };
|
|
823
|
+
}
|
|
824
|
+
/** Handle an inbound task received via the relay */
|
|
825
|
+
handleRelayTask(msg) {
|
|
826
|
+
const payload = msg.payload;
|
|
827
|
+
const params = payload.params || payload;
|
|
828
|
+
const rpcId = payload.id || msg.id;
|
|
829
|
+
this.handleSendMessage(params, rpcId).then((response) => {
|
|
830
|
+
this.relayClient?.sendTaskResponse(msg, response);
|
|
831
|
+
}).catch((err) => {
|
|
832
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
833
|
+
this.logger.error(`[perkos-a2a] Failed to process relay task: ${errMsg}`);
|
|
834
|
+
this.relayClient?.sendTaskResponse(msg, this.error(rpcId, -32603, errMsg));
|
|
189
835
|
});
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
description: "Name of the target agent (e.g. 'mimir', 'tyr', 'bragi', 'idunn')",
|
|
201
|
-
},
|
|
202
|
-
message: {
|
|
203
|
-
type: "string",
|
|
204
|
-
description: "The task message to send to the agent",
|
|
205
|
-
},
|
|
206
|
-
},
|
|
207
|
-
required: ["target", "message"],
|
|
208
|
-
},
|
|
209
|
-
async execute(_id, params) {
|
|
210
|
-
try {
|
|
211
|
-
const result = await server.sendTask(params.target, params.message);
|
|
212
|
-
if (result.error) {
|
|
213
|
-
return {
|
|
214
|
-
content: [
|
|
215
|
-
{
|
|
216
|
-
type: "text",
|
|
217
|
-
text: `Failed to send task to ${params.target}: ${result.error.message}`,
|
|
218
|
-
},
|
|
219
|
-
],
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
const task = result.result;
|
|
223
|
-
return {
|
|
224
|
-
content: [
|
|
225
|
-
{
|
|
226
|
-
type: "text",
|
|
227
|
-
text: [
|
|
228
|
-
`Task sent to ${params.target} successfully.`,
|
|
229
|
-
`Task ID: ${task?.id}`,
|
|
230
|
-
`Status: ${task?.status?.state}`,
|
|
231
|
-
].join("\n"),
|
|
232
|
-
},
|
|
233
|
-
],
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
catch (err) {
|
|
237
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
238
|
-
return {
|
|
239
|
-
content: [{ type: "text", text: `Error sending task: ${msg}` }],
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
},
|
|
836
|
+
}
|
|
837
|
+
startRelayClient() {
|
|
838
|
+
const relay = this.config.relay;
|
|
839
|
+
if (!relay?.enabled || !relay?.url) return;
|
|
840
|
+
this.relayClient = new RelayClient({
|
|
841
|
+
agentName: this.config.agentName,
|
|
842
|
+
relay,
|
|
843
|
+
card: this.agentCard,
|
|
844
|
+
onTask: (msg) => this.handleRelayTask(msg),
|
|
845
|
+
logger: this.logger
|
|
243
846
|
});
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
847
|
+
this.relayClient.start();
|
|
848
|
+
this.logger.info(`[perkos-a2a] Relay client started, connecting to ${relay.url}`);
|
|
849
|
+
}
|
|
850
|
+
startRelayHub() {
|
|
851
|
+
const relayConfig = this.config.relay;
|
|
852
|
+
this.relayHub = new RelayHub(
|
|
853
|
+
{
|
|
854
|
+
port: this.config.port,
|
|
855
|
+
apiKeys: this.config.auth?.apiKeys || [],
|
|
856
|
+
maxQueuePerAgent: 200,
|
|
857
|
+
rateLimitPerMinute: 60,
|
|
858
|
+
heartbeatIntervalMs: 3e4,
|
|
859
|
+
heartbeatTimeoutMs: 9e4
|
|
860
|
+
},
|
|
861
|
+
this.logger
|
|
862
|
+
);
|
|
863
|
+
this.relayHub.start();
|
|
864
|
+
}
|
|
865
|
+
async start() {
|
|
866
|
+
const mode = this.config.mode || "auto";
|
|
867
|
+
if (mode === "relay") {
|
|
868
|
+
this.clientOnly = true;
|
|
869
|
+
this.startRelayHub();
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
if (mode === "client-only") {
|
|
873
|
+
this.clientOnly = true;
|
|
874
|
+
this.startRelayClient();
|
|
875
|
+
if (!this.config.relay?.enabled) {
|
|
876
|
+
this.logger.info(
|
|
877
|
+
"[perkos-a2a] Running in client-only mode. Configure relay for NAT traversal or set up Tailscale/tunnel."
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
if (mode === "full") {
|
|
883
|
+
this.tryListen(this.config.port);
|
|
884
|
+
this.startRelayClient();
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
try {
|
|
888
|
+
const net = await detectNetworking();
|
|
889
|
+
if (net.isBehindNat && !net.hasTailscale) {
|
|
890
|
+
if (this.config.relay?.enabled) {
|
|
891
|
+
this.logger.info(
|
|
892
|
+
"[perkos-a2a] Behind NAT, using relay for bidirectional communication"
|
|
893
|
+
);
|
|
894
|
+
this.clientOnly = true;
|
|
895
|
+
this.startRelayClient();
|
|
896
|
+
} else {
|
|
897
|
+
this.logger.info(
|
|
898
|
+
"[perkos-a2a] Running in client-only mode (behind NAT). Configure relay or Tailscale for bidirectional A2A."
|
|
899
|
+
);
|
|
900
|
+
this.clientOnly = true;
|
|
901
|
+
}
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
if (net.isBehindNat && net.hasTailscale && net.tailscaleIp) {
|
|
905
|
+
this.logger.info(
|
|
906
|
+
`[perkos-a2a] Behind NAT but Tailscale detected (${net.tailscaleIp}). Starting server.`
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
} catch {
|
|
910
|
+
}
|
|
911
|
+
this.tryListen(this.config.port);
|
|
912
|
+
this.startRelayClient();
|
|
913
|
+
}
|
|
914
|
+
tryListen(port, attempt = 1) {
|
|
915
|
+
const maxAttempts = 3;
|
|
916
|
+
try {
|
|
917
|
+
const srv = this.app.listen(port, "0.0.0.0", () => {
|
|
918
|
+
this.logger.info(
|
|
919
|
+
`[perkos-a2a] ${this.config.agentName} server on port ${port}`
|
|
920
|
+
);
|
|
921
|
+
this.agentCard.url = `http://localhost:${port}/a2a/jsonrpc`;
|
|
922
|
+
});
|
|
923
|
+
srv.on("error", (err) => {
|
|
924
|
+
if (err.code === "EADDRINUSE" && attempt < maxAttempts) {
|
|
925
|
+
const nextPort = port + 1;
|
|
926
|
+
this.logger.info(`[perkos-a2a] Port ${port} in use, trying ${nextPort}`);
|
|
927
|
+
this.tryListen(nextPort, attempt + 1);
|
|
928
|
+
} else if (err.code === "EADDRINUSE") {
|
|
929
|
+
this.logger.info(
|
|
930
|
+
`[perkos-a2a] Ports ${this.config.port}-${port} all in use. Falling back to client-only mode.`
|
|
931
|
+
);
|
|
932
|
+
this.clientOnly = true;
|
|
933
|
+
} else {
|
|
934
|
+
this.logger.error(`[perkos-a2a] Server error: ${err.message}`);
|
|
935
|
+
}
|
|
936
|
+
});
|
|
937
|
+
} catch (err) {
|
|
938
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
939
|
+
this.logger.error(`[perkos-a2a] Failed to start server: ${msg}`);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
getExpressApp() {
|
|
943
|
+
return this.app;
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
// src/index.ts
|
|
948
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
949
|
+
import path from "path";
|
|
950
|
+
function wakeGatewayAgent(requestHeartbeatNow, reason, logger, sessionKey = "agent:main") {
|
|
951
|
+
if (typeof requestHeartbeatNow === "function") {
|
|
952
|
+
try {
|
|
953
|
+
requestHeartbeatNow({ reason, sessionKey });
|
|
954
|
+
logger.info(`[perkos-a2a] Wake triggered via runtime.system.requestHeartbeatNow: ${reason} (session: ${sessionKey})`);
|
|
955
|
+
} catch (err) {
|
|
956
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
957
|
+
logger.error(`[perkos-a2a] runtime.system.requestHeartbeatNow failed: ${msg}`);
|
|
958
|
+
}
|
|
959
|
+
} else {
|
|
960
|
+
logger.info("[perkos-a2a] runtime.system.requestHeartbeatNow unavailable \u2014 task will process on next agent turn");
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
function register(api) {
|
|
964
|
+
const pluginConfig = api.config?.plugins?.entries?.["perkos-a2a"]?.config || {
|
|
965
|
+
agentName: "agent",
|
|
966
|
+
port: 5050,
|
|
967
|
+
skills: [],
|
|
968
|
+
peers: {}
|
|
969
|
+
};
|
|
970
|
+
const logger = api.logger || console;
|
|
971
|
+
const server = new A2AServer(pluginConfig, logger);
|
|
972
|
+
const pendingTasks = [];
|
|
973
|
+
const enqueueSystemEvent = api.runtime?.system?.enqueueSystemEvent;
|
|
974
|
+
const requestHeartbeatNow = api.runtime?.system?.requestHeartbeatNow;
|
|
975
|
+
if (enqueueSystemEvent) {
|
|
976
|
+
logger.info("[perkos-a2a] enqueueSystemEvent available \u2014 will inject tasks as system events + wake");
|
|
977
|
+
} else {
|
|
978
|
+
logger.info("[perkos-a2a] enqueueSystemEvent not available \u2014 falling back to before_agent_start hook only");
|
|
979
|
+
}
|
|
980
|
+
if (requestHeartbeatNow) {
|
|
981
|
+
logger.info("[perkos-a2a] runtime.system.requestHeartbeatNow available \u2014 immediate wake enabled");
|
|
982
|
+
} else {
|
|
983
|
+
logger.info("[perkos-a2a] runtime.system.requestHeartbeatNow unavailable \u2014 wake will rely on next agent turn");
|
|
984
|
+
}
|
|
985
|
+
server.setTaskResultHandler(async (task, text) => {
|
|
986
|
+
const cfg = await api.runtime?.config?.loadConfig?.();
|
|
987
|
+
if (!api.runtime?.agent?.runEmbeddedAgent || !cfg) {
|
|
988
|
+
task.status = {
|
|
989
|
+
state: "completed",
|
|
990
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
991
|
+
};
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
const agentDir = api.runtime.agent.resolveAgentDir(cfg);
|
|
995
|
+
const workspaceDir = api.runtime.agent.resolveAgentWorkspaceDir(cfg);
|
|
996
|
+
await api.runtime.agent.ensureAgentWorkspace(cfg);
|
|
997
|
+
const sessionId = `perkos-a2a:task:${task.id}`;
|
|
998
|
+
const prompt = [
|
|
999
|
+
`You are handling an incoming A2A task from agent ${task.metadata?.fromAgent || "unknown"}.`,
|
|
1000
|
+
`Task ID: ${task.id}`,
|
|
1001
|
+
`Context ID: ${task.contextId}`,
|
|
1002
|
+
"",
|
|
1003
|
+
"Execute the request below and return the actual final answer for the peer agent.",
|
|
1004
|
+
"Do not describe internal steps unless the task explicitly asks for them.",
|
|
1005
|
+
"Return only the useful final response.",
|
|
1006
|
+
"",
|
|
1007
|
+
text
|
|
1008
|
+
].join("\n");
|
|
1009
|
+
const result = await api.runtime.agent.runEmbeddedAgent({
|
|
1010
|
+
sessionId,
|
|
1011
|
+
runId: randomUUID4(),
|
|
1012
|
+
sessionFile: path.join(agentDir, "sessions", `perkos-a2a-task-${task.id}.jsonl`),
|
|
1013
|
+
workspaceDir,
|
|
1014
|
+
prompt,
|
|
1015
|
+
timeoutMs: api.runtime.agent.resolveAgentTimeoutMs(cfg)
|
|
282
1016
|
});
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
1017
|
+
const finalText = (result?.meta?.finalAssistantVisibleText || result?.payloads?.map((p) => p?.text).filter(Boolean).join("\n\n") || "").trim();
|
|
1018
|
+
if (finalText) {
|
|
1019
|
+
task.artifacts.push({
|
|
1020
|
+
kind: "artifact",
|
|
1021
|
+
artifactId: randomUUID4(),
|
|
1022
|
+
parts: [{ kind: "text", text: finalText }]
|
|
1023
|
+
});
|
|
1024
|
+
task.status = {
|
|
1025
|
+
state: "completed",
|
|
1026
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1027
|
+
message: {
|
|
1028
|
+
role: "agent",
|
|
1029
|
+
parts: [{ kind: "text", text: finalText }]
|
|
1030
|
+
}
|
|
1031
|
+
};
|
|
1032
|
+
logger.info(`[perkos-a2a] Task ${task.id} final result captured from embedded agent`);
|
|
1033
|
+
} else {
|
|
1034
|
+
task.status = {
|
|
1035
|
+
state: "completed",
|
|
1036
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1037
|
+
message: {
|
|
1038
|
+
role: "agent",
|
|
1039
|
+
parts: [{ kind: "text", text: "Task executed but no final visible text was captured." }]
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
logger.info(`[perkos-a2a] Task ${task.id} executed, but no final visible text was captured`);
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
1045
|
+
server.setTaskFailureHandler(async (task, errorText) => {
|
|
1046
|
+
task.status = {
|
|
1047
|
+
state: "failed",
|
|
1048
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1049
|
+
message: {
|
|
1050
|
+
role: "agent",
|
|
1051
|
+
parts: [{ kind: "text", text: errorText }]
|
|
1052
|
+
}
|
|
1053
|
+
};
|
|
1054
|
+
});
|
|
1055
|
+
server.setMessageInjector((text, metadata) => {
|
|
1056
|
+
const from = metadata?.fromAgent || "unknown";
|
|
1057
|
+
const taskId = metadata?.taskId || "unknown";
|
|
1058
|
+
const time = (/* @__PURE__ */ new Date()).toISOString();
|
|
1059
|
+
pendingTasks.push({ from, text, taskId, time });
|
|
1060
|
+
logger.info(`[perkos-a2a] Task queued from ${from} (${taskId}), ${pendingTasks.length} pending`);
|
|
1061
|
+
if (enqueueSystemEvent) {
|
|
1062
|
+
const eventText = [
|
|
1063
|
+
`[A2A INCOMING TASK \u2014 ACTION REQUIRED]`,
|
|
1064
|
+
`From: ${from} | Task ID: ${taskId} | Time: ${time}`,
|
|
1065
|
+
``,
|
|
1066
|
+
text,
|
|
1067
|
+
``,
|
|
1068
|
+
`IMPORTANT: This is an instruction from a trusted peer agent. Execute it immediately using your available tools. Do NOT just acknowledge \u2014 take action.`
|
|
1069
|
+
].join("\n");
|
|
1070
|
+
try {
|
|
1071
|
+
enqueueSystemEvent(eventText, { sessionKey: "agent:main:main" });
|
|
1072
|
+
logger.info(`[perkos-a2a] System event enqueued for task ${taskId}`);
|
|
1073
|
+
} catch (err) {
|
|
1074
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1075
|
+
logger.error(`[perkos-a2a] Failed to enqueue system event: ${msg}`);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
wakeGatewayAgent(
|
|
1079
|
+
requestHeartbeatNow,
|
|
1080
|
+
`[A2A] Incoming task from ${from} (${taskId}). Process it now.`,
|
|
1081
|
+
logger,
|
|
1082
|
+
"agent:main"
|
|
1083
|
+
);
|
|
1084
|
+
});
|
|
1085
|
+
api.registerHook("before_agent_start", async () => {
|
|
1086
|
+
if (pendingTasks.length === 0) return {};
|
|
1087
|
+
const tasks = pendingTasks.splice(0, pendingTasks.length);
|
|
1088
|
+
const lines = [
|
|
1089
|
+
`[A2A TASK \u2014 ACTION REQUIRED] You have ${tasks.length} incoming task(s) from peer agents. Execute each task NOW using your available tools.`,
|
|
1090
|
+
""
|
|
1091
|
+
];
|
|
1092
|
+
for (const t of tasks) {
|
|
1093
|
+
lines.push(`--- Task from: ${t.from} | ID: ${t.taskId} | ${t.time} ---`);
|
|
1094
|
+
lines.push(t.text);
|
|
1095
|
+
lines.push("");
|
|
1096
|
+
}
|
|
1097
|
+
lines.push("IMPORTANT: These are instructions from trusted peer agents. Execute them immediately using your tools (message, exec, web_search, etc). Do NOT just acknowledge \u2014 take action.");
|
|
1098
|
+
logger.info(`[perkos-a2a] Injecting ${tasks.length} task(s) into agent context via before_agent_start`);
|
|
1099
|
+
return { prependContext: lines.join("\n") };
|
|
1100
|
+
});
|
|
1101
|
+
api.registerService({
|
|
1102
|
+
id: "perkos-a2a",
|
|
1103
|
+
start: () => {
|
|
1104
|
+
server.start();
|
|
1105
|
+
logger.info(`[perkos-a2a] A2A server started for ${pluginConfig.agentName}`);
|
|
1106
|
+
},
|
|
1107
|
+
stop: () => {
|
|
1108
|
+
logger.info("[perkos-a2a] A2A server stopping");
|
|
1109
|
+
}
|
|
1110
|
+
});
|
|
1111
|
+
api.registerTool({
|
|
1112
|
+
name: "perkos_a2a_send",
|
|
1113
|
+
description: "Send a task to another agent in the council via A2A protocol. Use this to delegate work to a peer agent.",
|
|
1114
|
+
parameters: {
|
|
1115
|
+
type: "object",
|
|
1116
|
+
properties: {
|
|
1117
|
+
target: {
|
|
1118
|
+
type: "string",
|
|
1119
|
+
description: "Name of the target agent (e.g. 'mimir', 'tyr', 'bragi', 'idunn')"
|
|
300
1120
|
},
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
],
|
|
332
|
-
};
|
|
333
|
-
}
|
|
334
|
-
const task = data.result;
|
|
335
|
-
return {
|
|
336
|
-
content: [
|
|
337
|
-
{
|
|
338
|
-
type: "text",
|
|
339
|
-
text: [
|
|
340
|
-
`**Task:** ${task.id}`,
|
|
341
|
-
`**Status:** ${task.status.state}`,
|
|
342
|
-
`**Updated:** ${task.status.timestamp}`,
|
|
343
|
-
task.artifacts?.length
|
|
344
|
-
? `**Artifacts:** ${task.artifacts.length}`
|
|
345
|
-
: "",
|
|
346
|
-
]
|
|
347
|
-
.filter(Boolean)
|
|
348
|
-
.join("\n"),
|
|
349
|
-
},
|
|
350
|
-
],
|
|
351
|
-
};
|
|
352
|
-
}
|
|
353
|
-
catch (err) {
|
|
354
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
355
|
-
return {
|
|
356
|
-
content: [{ type: "text", text: `Error: ${msg}` }],
|
|
357
|
-
};
|
|
1121
|
+
message: {
|
|
1122
|
+
type: "string",
|
|
1123
|
+
description: "The task message to send to the agent"
|
|
1124
|
+
}
|
|
1125
|
+
},
|
|
1126
|
+
required: ["target", "message"]
|
|
1127
|
+
},
|
|
1128
|
+
async execute(_id, params) {
|
|
1129
|
+
try {
|
|
1130
|
+
const result = await server.sendTask(params.target, params.message);
|
|
1131
|
+
if (result.error) {
|
|
1132
|
+
return {
|
|
1133
|
+
content: [
|
|
1134
|
+
{
|
|
1135
|
+
type: "text",
|
|
1136
|
+
text: `Failed to send task to ${params.target}: ${result.error.message}`
|
|
1137
|
+
}
|
|
1138
|
+
]
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
const task = result.result;
|
|
1142
|
+
return {
|
|
1143
|
+
content: [
|
|
1144
|
+
{
|
|
1145
|
+
type: "text",
|
|
1146
|
+
text: [
|
|
1147
|
+
`Task sent to ${params.target} successfully.`,
|
|
1148
|
+
`Task ID: ${task?.id}`,
|
|
1149
|
+
`Status: ${task?.status?.state}`
|
|
1150
|
+
].join("\n")
|
|
358
1151
|
}
|
|
1152
|
+
]
|
|
1153
|
+
};
|
|
1154
|
+
} catch (err) {
|
|
1155
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1156
|
+
return {
|
|
1157
|
+
content: [{ type: "text", text: `Error sending task: ${msg}` }]
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
});
|
|
1162
|
+
api.registerTool({
|
|
1163
|
+
name: "perkos_a2a_discover",
|
|
1164
|
+
description: "Discover all peer agents in the council and their capabilities. Returns each agent's status, name, description, and skills.",
|
|
1165
|
+
parameters: {
|
|
1166
|
+
type: "object",
|
|
1167
|
+
properties: {}
|
|
1168
|
+
},
|
|
1169
|
+
async execute() {
|
|
1170
|
+
try {
|
|
1171
|
+
const peers = await server.discoverPeers();
|
|
1172
|
+
const lines = ["## Council Agents\n"];
|
|
1173
|
+
for (const [name, info] of Object.entries(peers)) {
|
|
1174
|
+
if (info.status === "online" && info.card) {
|
|
1175
|
+
lines.push(`### ${info.card.name} (${name})`);
|
|
1176
|
+
lines.push(`- **Status:** online`);
|
|
1177
|
+
lines.push(`- **Description:** ${info.card.description}`);
|
|
1178
|
+
lines.push(
|
|
1179
|
+
`- **Skills:** ${info.card.skills.map((s) => s.name).join(", ")}`
|
|
1180
|
+
);
|
|
1181
|
+
lines.push("");
|
|
1182
|
+
} else {
|
|
1183
|
+
lines.push(`### ${name}`);
|
|
1184
|
+
lines.push(`- **Status:** offline`);
|
|
1185
|
+
lines.push("");
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
return {
|
|
1189
|
+
content: [{ type: "text", text: lines.join("\n") }]
|
|
1190
|
+
};
|
|
1191
|
+
} catch (err) {
|
|
1192
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1193
|
+
return {
|
|
1194
|
+
content: [{ type: "text", text: `Error discovering peers: ${msg}` }]
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
});
|
|
1199
|
+
api.registerTool({
|
|
1200
|
+
name: "perkos_a2a_status",
|
|
1201
|
+
description: "Get the status of a previously sent A2A task by its ID.",
|
|
1202
|
+
parameters: {
|
|
1203
|
+
type: "object",
|
|
1204
|
+
properties: {
|
|
1205
|
+
target: {
|
|
1206
|
+
type: "string",
|
|
1207
|
+
description: "Name of the agent that received the task"
|
|
359
1208
|
},
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
console.log(`Peers: ${Object.keys(pluginConfig.peers).join(", ") || "(none)"}`);
|
|
392
|
-
console.log(`System event injection: ${!!enqueueSystemEvent}`);
|
|
393
|
-
});
|
|
394
|
-
cmd
|
|
395
|
-
.command("discover")
|
|
396
|
-
.description("Discover peer agents")
|
|
397
|
-
.action(async () => {
|
|
398
|
-
const peers = await server.discoverPeers();
|
|
399
|
-
for (const [name, info] of Object.entries(peers)) {
|
|
400
|
-
console.log(`${name}: ${info.status}${info.card ? ` -- ${info.card.description}` : ""}`);
|
|
401
|
-
}
|
|
402
|
-
});
|
|
403
|
-
cmd
|
|
404
|
-
.command("send <target> <message>")
|
|
405
|
-
.description("Send a task to a peer agent")
|
|
406
|
-
.action(async (target, message) => {
|
|
407
|
-
const result = await server.sendTask(target, message);
|
|
408
|
-
console.log(JSON.stringify(result, null, 2));
|
|
1209
|
+
taskId: {
|
|
1210
|
+
type: "string",
|
|
1211
|
+
description: "The task ID returned from perkos_a2a_send"
|
|
1212
|
+
}
|
|
1213
|
+
},
|
|
1214
|
+
required: ["target", "taskId"]
|
|
1215
|
+
},
|
|
1216
|
+
async execute(_id, params) {
|
|
1217
|
+
const targetUrl = pluginConfig.peers[params.target];
|
|
1218
|
+
if (!targetUrl) {
|
|
1219
|
+
return {
|
|
1220
|
+
content: [
|
|
1221
|
+
{ type: "text", text: `Unknown agent: ${params.target}` }
|
|
1222
|
+
]
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
try {
|
|
1226
|
+
const headers = { "Content-Type": "application/json" };
|
|
1227
|
+
const peerAuth = pluginConfig.peerAuth?.[params.target];
|
|
1228
|
+
if (peerAuth) {
|
|
1229
|
+
headers["x-api-key"] = peerAuth;
|
|
1230
|
+
}
|
|
1231
|
+
const response = await fetch(`${targetUrl}/a2a/jsonrpc`, {
|
|
1232
|
+
method: "POST",
|
|
1233
|
+
headers,
|
|
1234
|
+
body: JSON.stringify({
|
|
1235
|
+
jsonrpc: "2.0",
|
|
1236
|
+
method: "tasks/get",
|
|
1237
|
+
id: crypto.randomUUID(),
|
|
1238
|
+
params: { id: params.taskId }
|
|
1239
|
+
})
|
|
409
1240
|
});
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
portAvailable = false;
|
|
430
|
-
resolve();
|
|
431
|
-
}
|
|
432
|
-
else {
|
|
433
|
-
reject(err);
|
|
434
|
-
}
|
|
435
|
-
});
|
|
436
|
-
srv.listen(pluginConfig.port, () => {
|
|
437
|
-
srv.close(() => resolve());
|
|
438
|
-
});
|
|
439
|
-
});
|
|
440
|
-
}
|
|
441
|
-
catch {
|
|
442
|
-
// ignore
|
|
443
|
-
}
|
|
444
|
-
console.log(`Port ${pluginConfig.port}: ${portAvailable ? "available" : "IN USE"}`);
|
|
445
|
-
console.log(`\nRelay: ${pluginConfig.relay?.enabled ? "enabled" : "disabled"}`);
|
|
446
|
-
if (pluginConfig.relay?.url) {
|
|
447
|
-
console.log(`Relay URL: ${pluginConfig.relay.url}`);
|
|
448
|
-
}
|
|
449
|
-
console.log(`Auth: ${pluginConfig.auth?.requireApiKey ? "enabled" : "disabled"}`);
|
|
450
|
-
console.log(`System event injection: ${!!enqueueSystemEvent}`);
|
|
451
|
-
console.log("\n--- Recommendations ---\n");
|
|
452
|
-
if (!net.isBehindNat) {
|
|
453
|
-
console.log("You have a public IP. Configure peers with your public IP.");
|
|
454
|
-
console.log(` Your A2A URL: http://${net.publicIp}:${pluginConfig.port}/a2a/jsonrpc`);
|
|
455
|
-
}
|
|
456
|
-
else if (pluginConfig.relay?.enabled) {
|
|
457
|
-
console.log("You are behind NAT but relay is configured. Bidirectional communication via relay.");
|
|
458
|
-
}
|
|
459
|
-
else if (net.hasTailscale && net.tailscaleIp) {
|
|
460
|
-
console.log("Use your Tailscale IP for peers.");
|
|
461
|
-
console.log(` Your A2A URL: http://${net.tailscaleIp}:${pluginConfig.port}/a2a/jsonrpc`);
|
|
462
|
-
}
|
|
463
|
-
else {
|
|
464
|
-
console.log("You are behind NAT. Options:");
|
|
465
|
-
console.log(" 1) Configure a relay hub (recommended) - set relay.url and relay.enabled in config");
|
|
466
|
-
console.log(" 2) Install Tailscale - https://tailscale.com");
|
|
467
|
-
console.log(" 3) Set up a Cloudflare Tunnel");
|
|
468
|
-
console.log(' 4) Client-only mode (can send but not receive) - set mode: "client-only"');
|
|
1241
|
+
const data = await response.json();
|
|
1242
|
+
if (data.error) {
|
|
1243
|
+
return {
|
|
1244
|
+
content: [
|
|
1245
|
+
{ type: "text", text: `Error: ${data.error.message}` }
|
|
1246
|
+
]
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
const task = data.result;
|
|
1250
|
+
return {
|
|
1251
|
+
content: [
|
|
1252
|
+
{
|
|
1253
|
+
type: "text",
|
|
1254
|
+
text: [
|
|
1255
|
+
`**Task:** ${task.id}`,
|
|
1256
|
+
`**Status:** ${task.status.state}`,
|
|
1257
|
+
`**Updated:** ${task.status.timestamp}`,
|
|
1258
|
+
task.artifacts?.length ? `**Artifacts:** ${task.artifacts.length}` : ""
|
|
1259
|
+
].filter(Boolean).join("\n")
|
|
469
1260
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
1261
|
+
]
|
|
1262
|
+
};
|
|
1263
|
+
} catch (err) {
|
|
1264
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1265
|
+
return {
|
|
1266
|
+
content: [{ type: "text", text: `Error: ${msg}` }]
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
});
|
|
1271
|
+
api.registerGatewayMethod("perkos-a2a.status", ({ respond }) => {
|
|
1272
|
+
respond(true, {
|
|
1273
|
+
agent: pluginConfig.agentName,
|
|
1274
|
+
port: pluginConfig.port,
|
|
1275
|
+
mode: pluginConfig.mode || "auto",
|
|
1276
|
+
clientOnly: server.isClientOnly(),
|
|
1277
|
+
relayConnected: server.isRelayConnected(),
|
|
1278
|
+
relayUrl: pluginConfig.relay?.url || null,
|
|
1279
|
+
peers: Object.keys(pluginConfig.peers),
|
|
1280
|
+
pendingTasks: pendingTasks.length,
|
|
1281
|
+
hasSystemEventInjection: !!enqueueSystemEvent,
|
|
1282
|
+
protocol: "a2a",
|
|
1283
|
+
version: "0.8.4"
|
|
1284
|
+
});
|
|
1285
|
+
});
|
|
1286
|
+
api.registerCli(
|
|
1287
|
+
({ program }) => {
|
|
1288
|
+
const cmd = program.command("perkos-a2a").description("PerkOS A2A protocol tools");
|
|
1289
|
+
cmd.command("status").description("Show A2A agent status").action(async () => {
|
|
1290
|
+
console.log(`Agent: ${pluginConfig.agentName}`);
|
|
1291
|
+
console.log(`Port: ${pluginConfig.port}`);
|
|
1292
|
+
console.log(`Mode: ${pluginConfig.mode || "auto"}`);
|
|
1293
|
+
console.log(`Client-only: ${server.isClientOnly()}`);
|
|
1294
|
+
console.log(`Relay connected: ${server.isRelayConnected()}`);
|
|
1295
|
+
console.log(`Relay URL: ${pluginConfig.relay?.url || "(not configured)"}`);
|
|
1296
|
+
console.log(`Auth required: ${pluginConfig.auth?.requireApiKey || false}`);
|
|
1297
|
+
console.log(`Peers: ${Object.keys(pluginConfig.peers).join(", ") || "(none)"}`);
|
|
1298
|
+
console.log(`System event injection: ${!!enqueueSystemEvent}`);
|
|
1299
|
+
});
|
|
1300
|
+
cmd.command("discover").description("Discover peer agents").action(async () => {
|
|
1301
|
+
const peers = await server.discoverPeers();
|
|
1302
|
+
for (const [name, info] of Object.entries(peers)) {
|
|
1303
|
+
console.log(
|
|
1304
|
+
`${name}: ${info.status}${info.card ? ` -- ${info.card.description}` : ""}`
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
});
|
|
1308
|
+
cmd.command("send <target> <message>").description("Send a task to a peer agent").action(async (target, message) => {
|
|
1309
|
+
const result = await server.sendTask(target, message);
|
|
1310
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1311
|
+
});
|
|
1312
|
+
cmd.command("setup").description("Detect networking environment and show recommendations").action(async () => {
|
|
1313
|
+
console.log("[perkos-a2a] Detecting environment...\n");
|
|
1314
|
+
const isMac = process.platform === "darwin";
|
|
1315
|
+
console.log(`Platform: ${process.platform}${isMac ? " (macOS)" : ""}`);
|
|
1316
|
+
const net = await detectNetworking();
|
|
1317
|
+
console.log(`Public IP: ${net.publicIp || "unknown"}`);
|
|
1318
|
+
console.log(`Local IPs: ${net.localIps.join(", ") || "none"}`);
|
|
1319
|
+
console.log(`Behind NAT: ${net.isBehindNat ? "yes" : "no"}`);
|
|
1320
|
+
console.log(`Tailscale: ${net.hasTailscale ? "installed" : "not found"}${net.tailscaleIp ? ` (${net.tailscaleIp})` : ""}`);
|
|
1321
|
+
let portAvailable = true;
|
|
1322
|
+
try {
|
|
1323
|
+
const netMod = await import("net");
|
|
1324
|
+
await new Promise((resolve, reject) => {
|
|
1325
|
+
const srv = netMod.createServer();
|
|
1326
|
+
srv.once("error", (err) => {
|
|
1327
|
+
if (err.code === "EADDRINUSE") {
|
|
1328
|
+
portAvailable = false;
|
|
1329
|
+
resolve();
|
|
1330
|
+
} else {
|
|
1331
|
+
reject(err);
|
|
1332
|
+
}
|
|
1333
|
+
});
|
|
1334
|
+
srv.listen(pluginConfig.port, () => {
|
|
1335
|
+
srv.close(() => resolve());
|
|
1336
|
+
});
|
|
1337
|
+
});
|
|
1338
|
+
} catch {
|
|
1339
|
+
}
|
|
1340
|
+
console.log(`Port ${pluginConfig.port}: ${portAvailable ? "available" : "IN USE"}`);
|
|
1341
|
+
console.log(`
|
|
1342
|
+
Relay: ${pluginConfig.relay?.enabled ? "enabled" : "disabled"}`);
|
|
1343
|
+
if (pluginConfig.relay?.url) {
|
|
1344
|
+
console.log(`Relay URL: ${pluginConfig.relay.url}`);
|
|
1345
|
+
}
|
|
1346
|
+
console.log(`Auth: ${pluginConfig.auth?.requireApiKey ? "enabled" : "disabled"}`);
|
|
1347
|
+
console.log(`System event injection: ${!!enqueueSystemEvent}`);
|
|
1348
|
+
console.log("\n--- Recommendations ---\n");
|
|
1349
|
+
if (!net.isBehindNat) {
|
|
1350
|
+
console.log("You have a public IP. Configure peers with your public IP.");
|
|
1351
|
+
console.log(` Your A2A URL: http://${net.publicIp}:${pluginConfig.port}/a2a/jsonrpc`);
|
|
1352
|
+
} else if (pluginConfig.relay?.enabled) {
|
|
1353
|
+
console.log("You are behind NAT but relay is configured. Bidirectional communication via relay.");
|
|
1354
|
+
} else if (net.hasTailscale && net.tailscaleIp) {
|
|
1355
|
+
console.log("Use your Tailscale IP for peers.");
|
|
1356
|
+
console.log(` Your A2A URL: http://${net.tailscaleIp}:${pluginConfig.port}/a2a/jsonrpc`);
|
|
1357
|
+
} else {
|
|
1358
|
+
console.log("You are behind NAT. Options:");
|
|
1359
|
+
console.log(" 1) Configure a relay hub (recommended) - set relay.url and relay.enabled in config");
|
|
1360
|
+
console.log(" 2) Install Tailscale - https://tailscale.com");
|
|
1361
|
+
console.log(" 3) Set up a Cloudflare Tunnel");
|
|
1362
|
+
console.log(' 4) Client-only mode (can send but not receive) - set mode: "client-only"');
|
|
1363
|
+
}
|
|
1364
|
+
console.log("\n--- Current Config ---\n");
|
|
1365
|
+
console.log(`Agent: ${pluginConfig.agentName}`);
|
|
1366
|
+
console.log(`Port: ${pluginConfig.port}`);
|
|
1367
|
+
console.log(`Mode: ${pluginConfig.mode || "auto"}`);
|
|
1368
|
+
console.log(`Peers: ${JSON.stringify(pluginConfig.peers, null, 2)}`);
|
|
1369
|
+
});
|
|
1370
|
+
},
|
|
1371
|
+
{ commands: ["perkos-a2a"] }
|
|
1372
|
+
);
|
|
477
1373
|
}
|
|
478
|
-
|
|
1374
|
+
export {
|
|
1375
|
+
A2AServer,
|
|
1376
|
+
RelayClient,
|
|
1377
|
+
RelayHub,
|
|
1378
|
+
register as default,
|
|
1379
|
+
detectNetworking
|
|
1380
|
+
};
|
|
1381
|
+
//# sourceMappingURL=index.js.map
|