a2acalling 0.6.66 → 0.6.67
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/.a2a-manifest.json +2 -2
- package/ARCHITECTURE.md +10 -1
- package/CONVENTIONS.md +18 -0
- package/package.json +1 -1
- package/src/lib/client.js +141 -29
package/.a2a-manifest.json
CHANGED
package/ARCHITECTURE.md
CHANGED
|
@@ -20,7 +20,7 @@ A2A Calling enables agent-to-agent communication across OpenClaw instances. Agen
|
|
|
20
20
|
┌───────────▼──────────────────────────────────────────────────────┐
|
|
21
21
|
│ Core Libraries (src/lib/) │
|
|
22
22
|
│ ├─ tokens.js Token CRUD, validation, tiers │
|
|
23
|
-
│ ├─ client.js A2AClient for outbound calls
|
|
23
|
+
│ ├─ client.js A2AClient for outbound calls (retry + size cap) │
|
|
24
24
|
│ ├─ conversations.js ConversationStore (SQLite) │
|
|
25
25
|
│ ├─ conversation-driver.js Multi-turn call orchestration │
|
|
26
26
|
│ ├─ summarizer.js Call summary generation │
|
|
@@ -28,6 +28,7 @@ A2A Calling enables agent-to-agent communication across OpenClaw instances. Agen
|
|
|
28
28
|
│ ├─ summary-formatter.js Format summaries for display │
|
|
29
29
|
│ ├─ disclosure.js Disclosure level enforcement │
|
|
30
30
|
│ ├─ config.js Config file management │
|
|
31
|
+
│ ├─ crypto.js Ed25519 identity keypair + signing │
|
|
31
32
|
│ ├─ logger.js Structured logger (SQLite + stdout) │
|
|
32
33
|
│ ├─ call-monitor.js Active call monitoring │
|
|
33
34
|
│ ├─ callbook.js Contact/callbook management │
|
|
@@ -80,6 +81,10 @@ Single-page app served from `src/dashboard/public/`. Uses Shoelace web component
|
|
|
80
81
|
|
|
81
82
|
Tauri v2 app at `native/macos/` wrapping the dashboard SPA. Provides native menus, notifications, and server lifecycle management.
|
|
82
83
|
|
|
84
|
+
## Identity Verification
|
|
85
|
+
|
|
86
|
+
Ed25519 cryptographic identity for agents. Each instance generates a keypair on first run (stored in config). Outbound calls sign messages; inbound calls verify signatures. Uses Node.js built-in `crypto.sign`/`crypto.verify` — no external dependencies. See `src/lib/crypto.js`.
|
|
87
|
+
|
|
83
88
|
## Testing
|
|
84
89
|
|
|
85
90
|
Zero-dependency test runner at `test/run.js` with custom assert API. Three test tiers:
|
|
@@ -90,3 +95,7 @@ Zero-dependency test runner at `test/run.js` with custom assert API. Three test
|
|
|
90
95
|
Test profiles at `test/profiles/` represent real personas with distinct permission tiers.
|
|
91
96
|
|
|
92
97
|
E2E test results are persisted to `~/.config/openclaw/a2a-e2e-results.json` via `test/e2e/persist.js` and surfaced in the dashboard Health tab. The `scripts/run-e2e.sh` orchestrator runs E2E suites and stores results.
|
|
98
|
+
|
|
99
|
+
## Network Resilience
|
|
100
|
+
|
|
101
|
+
The outbound A2A client (`src/lib/client.js`) retries transient network failures (ECONNRESET, ECONNREFUSED, EPIPE, ENOTFOUND, EAI_AGAIN, timeouts) with exponential backoff (0s, 1s, 2s). HTTP 4xx/5xx errors are not retried. All response accumulation is capped at 2MB to prevent OOM from malicious remotes.
|
package/CONVENTIONS.md
CHANGED
|
@@ -67,6 +67,24 @@ All modules use CommonJS (`require`/`module.exports`). Each lib file exports a f
|
|
|
67
67
|
- Sidebar navigation with tab switching (Contacts, Calls, Invites, Logs, Settings, Permissions, Health)
|
|
68
68
|
- Permissions tab uses tier cards with tool toggles and auto-save
|
|
69
69
|
|
|
70
|
+
## Network Resilience (A2A-54)
|
|
71
|
+
|
|
72
|
+
Outbound client methods (`call()`, `end()`) automatically retry transient network errors with exponential backoff. Pattern:
|
|
73
|
+
- Use `withRetry(fn, { delays })` for retryable operations
|
|
74
|
+
- Only retry on transient errors (ECONNRESET, ECONNREFUSED, EPIPE, ENOTFOUND, EAI_AGAIN, timeout)
|
|
75
|
+
- Never retry HTTP 4xx/5xx — those are explicit server rejections
|
|
76
|
+
- All HTTP responses are size-capped at 2MB via `handleSizeCappedResponse()`
|
|
77
|
+
- Configurable retry delays via `_retryDelays` constructor option (used in tests with `[0,0,0]` for fast execution)
|
|
78
|
+
|
|
79
|
+
## Dashboard API Testing (A2A-56)
|
|
80
|
+
|
|
81
|
+
Dashboard API integration tests follow the pattern in `test/integration/dashboard-logs.test.js`:
|
|
82
|
+
- Mount `createDashboardApiRouter()` on an Express app
|
|
83
|
+
- Use `helpers.request()` for HTTP assertions (binds to 127.0.0.1 — bypasses auth)
|
|
84
|
+
- Bust module caches for `dashboard`, `logger`, `tokens`, `config`, `disclosure`, `conversations`, `callbook`, `dashboard-events`
|
|
85
|
+
- Call `loggerModule.closeAllLoggerStores()` in teardown to prevent SQLite handle leaks
|
|
86
|
+
- Pass `convStore` directly via `options.convStore` when testing calls endpoints
|
|
87
|
+
|
|
70
88
|
## Permission Tiers
|
|
71
89
|
|
|
72
90
|
Tokens have a tier (`public`, `friends`, `family`) and a disclosure level (`public`, `minimal`, `none`). These are enforced at the route level in `src/routes/a2a.js`.
|
package/package.json
CHANGED
package/src/lib/client.js
CHANGED
|
@@ -5,6 +5,68 @@
|
|
|
5
5
|
const https = require('https');
|
|
6
6
|
const http = require('http');
|
|
7
7
|
const { signRequest } = require('./crypto');
|
|
8
|
+
// A2A-54: structured logging for retry warnings and size-cap violations
|
|
9
|
+
const { createLogger } = require('./logger');
|
|
10
|
+
|
|
11
|
+
const logger = createLogger({ component: 'a2a.client' });
|
|
12
|
+
|
|
13
|
+
// A2A-54: response size cap prevents OOM from unbounded accumulation
|
|
14
|
+
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
// A2A-54: only transient network errors are retryable — HTTP 4xx/5xx are not
|
|
17
|
+
const RETRYABLE_CODES = ['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ENOTFOUND', 'EAI_AGAIN'];
|
|
18
|
+
|
|
19
|
+
// A2A-54: exponential backoff — first retry is immediate, then 1s, then 2s
|
|
20
|
+
const RETRY_DELAYS = [0, 1000, 2000];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A2A-54: Retry wrapper for transient network failures.
|
|
24
|
+
* Only retries on RETRYABLE_CODES and timeout errors — HTTP status errors
|
|
25
|
+
* bubble up immediately since the remote explicitly rejected the request.
|
|
26
|
+
*
|
|
27
|
+
* @param {Function} fn - async function to retry
|
|
28
|
+
* @param {object} options
|
|
29
|
+
* @param {number[]} options.delays - delay sequence in ms (default: RETRY_DELAYS)
|
|
30
|
+
* @returns {Promise<*>}
|
|
31
|
+
*/
|
|
32
|
+
async function withRetry(fn, options = {}) {
|
|
33
|
+
const delays = options.delays || RETRY_DELAYS;
|
|
34
|
+
const maxAttempts = delays.length + 1;
|
|
35
|
+
|
|
36
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
37
|
+
try {
|
|
38
|
+
return await fn();
|
|
39
|
+
} catch (err) {
|
|
40
|
+
// A2A-54: only retry transient network errors and timeouts.
|
|
41
|
+
// HTTP 4xx/5xx errors have err.code set to the server's error code
|
|
42
|
+
// (e.g. 'bad_request'), so they won't match network_error or timeout.
|
|
43
|
+
const isRetryable = err instanceof A2AError && (
|
|
44
|
+
(err.code === 'network_error' && RETRYABLE_CODES.some(c => err.message.includes(c))) ||
|
|
45
|
+
err.code === 'timeout'
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
if (!isRetryable || attempt >= maxAttempts) {
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A2A-54: log each retry at warn level for operator visibility
|
|
53
|
+
const delay = delays[attempt - 1];
|
|
54
|
+
logger.warn(`Retrying request (attempt ${attempt + 1}/${maxAttempts})`, {
|
|
55
|
+
event: 'retry',
|
|
56
|
+
data: {
|
|
57
|
+
error_code: err.code,
|
|
58
|
+
error_message: err.message,
|
|
59
|
+
attempt: attempt + 1,
|
|
60
|
+
delay_ms: delay
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (delay > 0) {
|
|
65
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
8
70
|
|
|
9
71
|
function splitHostPort(rawHost) {
|
|
10
72
|
const host = String(rawHost || '').trim();
|
|
@@ -51,6 +113,41 @@ function resolveProtocolAndPort(host) {
|
|
|
51
113
|
return { protocol, hostname, port };
|
|
52
114
|
}
|
|
53
115
|
|
|
116
|
+
/**
|
|
117
|
+
* A2A-54: Create a size-capped response handler.
|
|
118
|
+
* Tracks accumulated bytes and destroys the socket if the cap is exceeded,
|
|
119
|
+
* preventing OOM from malicious or misconfigured remote agents.
|
|
120
|
+
*
|
|
121
|
+
* @param {http.IncomingMessage} res - the response stream
|
|
122
|
+
* @param {Function} resolve - promise resolve
|
|
123
|
+
* @param {Function} reject - promise reject
|
|
124
|
+
* @param {Function} onComplete - called with (data, statusCode) when response ends within cap
|
|
125
|
+
*/
|
|
126
|
+
function handleSizeCappedResponse(res, resolve, reject, onComplete) {
|
|
127
|
+
let data = '';
|
|
128
|
+
let bytes = 0;
|
|
129
|
+
let destroyed = false;
|
|
130
|
+
|
|
131
|
+
res.on('data', (chunk) => {
|
|
132
|
+
bytes += chunk.length;
|
|
133
|
+
if (bytes > MAX_RESPONSE_BYTES) {
|
|
134
|
+
if (!destroyed) {
|
|
135
|
+
destroyed = true;
|
|
136
|
+
res.destroy();
|
|
137
|
+
// A2A-54: reject immediately — the remote sent more data than we allow
|
|
138
|
+
reject(new A2AError('response_too_large', `Response exceeded ${MAX_RESPONSE_BYTES} bytes`));
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
data += chunk;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
res.on('end', () => {
|
|
146
|
+
if (destroyed) return;
|
|
147
|
+
onComplete(data, res.statusCode);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
54
151
|
class A2AClient {
|
|
55
152
|
constructor(options = {}) {
|
|
56
153
|
this.timeout = options.timeout || 60000;
|
|
@@ -58,6 +155,8 @@ class A2AClient {
|
|
|
58
155
|
// A2A-52: Ed25519 identity keys for request signing
|
|
59
156
|
this.privateKey = options.privateKey || null;
|
|
60
157
|
this.publicKey = options.publicKey || null;
|
|
158
|
+
// A2A-54: allow configurable retry delays for testing (fast tests use [0,0,0])
|
|
159
|
+
this._retryDelays = options._retryDelays || RETRY_DELAYS;
|
|
61
160
|
}
|
|
62
161
|
|
|
63
162
|
/**
|
|
@@ -88,7 +187,7 @@ class A2AClient {
|
|
|
88
187
|
|
|
89
188
|
/**
|
|
90
189
|
* Call a remote agent
|
|
91
|
-
*
|
|
190
|
+
*
|
|
92
191
|
* @param {string|object} endpoint - a2a:// URL or {host, token}
|
|
93
192
|
* @param {string} message - Message to send
|
|
94
193
|
* @param {object} options - Additional options
|
|
@@ -96,7 +195,7 @@ class A2AClient {
|
|
|
96
195
|
*/
|
|
97
196
|
async call(endpoint, message, options = {}) {
|
|
98
197
|
let host, token;
|
|
99
|
-
|
|
198
|
+
|
|
100
199
|
if (typeof endpoint === 'string') {
|
|
101
200
|
({ host, token } = A2AClient.parseInvite(endpoint));
|
|
102
201
|
} else {
|
|
@@ -117,7 +216,8 @@ class A2AClient {
|
|
|
117
216
|
// A2A-52: attach signature headers when keypair available
|
|
118
217
|
const sigHeaders = this._signHeaders('POST', '/api/a2a/invoke', body);
|
|
119
218
|
|
|
120
|
-
|
|
219
|
+
// A2A-54: wrap with retry for transient network failures
|
|
220
|
+
const makeRequest = () => new Promise((resolve, reject) => {
|
|
121
221
|
const req = protocol.request({
|
|
122
222
|
hostname,
|
|
123
223
|
port,
|
|
@@ -131,24 +231,23 @@ class A2AClient {
|
|
|
131
231
|
},
|
|
132
232
|
timeout: this.timeout
|
|
133
233
|
}, (res) => {
|
|
134
|
-
|
|
135
|
-
res
|
|
136
|
-
res.on('end', () => {
|
|
234
|
+
// A2A-54: size-capped response accumulation
|
|
235
|
+
handleSizeCappedResponse(res, resolve, reject, (data, statusCode) => {
|
|
137
236
|
try {
|
|
138
237
|
const json = JSON.parse(data);
|
|
139
|
-
if (
|
|
140
|
-
reject(new A2AError(json.error || 'request_failed', json.message || data,
|
|
238
|
+
if (statusCode >= 400) {
|
|
239
|
+
reject(new A2AError(json.error || 'request_failed', json.message || data, statusCode));
|
|
141
240
|
} else {
|
|
142
241
|
resolve(json);
|
|
143
242
|
}
|
|
144
243
|
} catch (e) {
|
|
145
|
-
reject(new A2AError('parse_error', `Failed to parse response: ${data}`,
|
|
244
|
+
reject(new A2AError('parse_error', `Failed to parse response: ${data}`, statusCode));
|
|
146
245
|
}
|
|
147
246
|
});
|
|
148
247
|
});
|
|
149
248
|
|
|
150
249
|
req.on('error', (e) => {
|
|
151
|
-
reject(new A2AError('network_error', e.message));
|
|
250
|
+
reject(new A2AError('network_error', e.code ? `${e.code}: ${e.message}` : e.message));
|
|
152
251
|
});
|
|
153
252
|
|
|
154
253
|
req.on('timeout', () => {
|
|
@@ -159,6 +258,8 @@ class A2AClient {
|
|
|
159
258
|
req.write(body);
|
|
160
259
|
req.end();
|
|
161
260
|
});
|
|
261
|
+
|
|
262
|
+
return withRetry(makeRequest, { delays: this._retryDelays });
|
|
162
263
|
}
|
|
163
264
|
|
|
164
265
|
/**
|
|
@@ -189,7 +290,8 @@ class A2AClient {
|
|
|
189
290
|
// A2A-52: attach signature headers when keypair available
|
|
190
291
|
const sigHeaders = this._signHeaders('POST', '/api/a2a/end', body);
|
|
191
292
|
|
|
192
|
-
|
|
293
|
+
// A2A-54: wrap with retry for transient network failures
|
|
294
|
+
const makeRequest = () => new Promise((resolve, reject) => {
|
|
193
295
|
const req = protocol.request({
|
|
194
296
|
hostname,
|
|
195
297
|
port,
|
|
@@ -203,24 +305,23 @@ class A2AClient {
|
|
|
203
305
|
},
|
|
204
306
|
timeout: this.timeout
|
|
205
307
|
}, (res) => {
|
|
206
|
-
|
|
207
|
-
res
|
|
208
|
-
res.on('end', () => {
|
|
308
|
+
// A2A-54: size-capped response accumulation
|
|
309
|
+
handleSizeCappedResponse(res, resolve, reject, (data, statusCode) => {
|
|
209
310
|
try {
|
|
210
311
|
const json = JSON.parse(data);
|
|
211
|
-
if (
|
|
212
|
-
reject(new A2AError(json.error || 'request_failed', json.message || data,
|
|
312
|
+
if (statusCode >= 400) {
|
|
313
|
+
reject(new A2AError(json.error || 'request_failed', json.message || data, statusCode));
|
|
213
314
|
} else {
|
|
214
315
|
resolve(json);
|
|
215
316
|
}
|
|
216
317
|
} catch (e) {
|
|
217
|
-
reject(new A2AError('parse_error', `Failed to parse response: ${data}`,
|
|
318
|
+
reject(new A2AError('parse_error', `Failed to parse response: ${data}`, statusCode));
|
|
218
319
|
}
|
|
219
320
|
});
|
|
220
321
|
});
|
|
221
322
|
|
|
222
323
|
req.on('error', (e) => {
|
|
223
|
-
reject(new A2AError('network_error', e.message));
|
|
324
|
+
reject(new A2AError('network_error', e.code ? `${e.code}: ${e.message}` : e.message));
|
|
224
325
|
});
|
|
225
326
|
|
|
226
327
|
req.on('timeout', () => {
|
|
@@ -231,6 +332,8 @@ class A2AClient {
|
|
|
231
332
|
req.write(body);
|
|
232
333
|
req.end();
|
|
233
334
|
});
|
|
335
|
+
|
|
336
|
+
return withRetry(makeRequest, { delays: this._retryDelays });
|
|
234
337
|
}
|
|
235
338
|
|
|
236
339
|
/**
|
|
@@ -238,7 +341,7 @@ class A2AClient {
|
|
|
238
341
|
*/
|
|
239
342
|
async ping(endpoint) {
|
|
240
343
|
let host;
|
|
241
|
-
|
|
344
|
+
|
|
242
345
|
if (typeof endpoint === 'string') {
|
|
243
346
|
({ host } = A2AClient.parseInvite(endpoint));
|
|
244
347
|
} else {
|
|
@@ -247,6 +350,7 @@ class A2AClient {
|
|
|
247
350
|
|
|
248
351
|
const { protocol, hostname, port } = resolveProtocolAndPort(host);
|
|
249
352
|
|
|
353
|
+
// A2A-54: no retry for ping — it's a lightweight probe, not a critical call
|
|
250
354
|
return new Promise((resolve, reject) => {
|
|
251
355
|
const req = protocol.request({
|
|
252
356
|
hostname,
|
|
@@ -255,13 +359,12 @@ class A2AClient {
|
|
|
255
359
|
method: 'GET',
|
|
256
360
|
timeout: 5000
|
|
257
361
|
}, (res) => {
|
|
258
|
-
|
|
259
|
-
res
|
|
260
|
-
res.on('end', () => {
|
|
362
|
+
// A2A-54: size-capped response accumulation
|
|
363
|
+
handleSizeCappedResponse(res, resolve, reject, (data, statusCode) => {
|
|
261
364
|
try {
|
|
262
365
|
resolve(JSON.parse(data));
|
|
263
366
|
} catch {
|
|
264
|
-
resolve({ pong:
|
|
367
|
+
resolve({ pong: statusCode === 200 });
|
|
265
368
|
}
|
|
266
369
|
});
|
|
267
370
|
});
|
|
@@ -280,7 +383,7 @@ class A2AClient {
|
|
|
280
383
|
*/
|
|
281
384
|
async status(endpoint) {
|
|
282
385
|
let host;
|
|
283
|
-
|
|
386
|
+
|
|
284
387
|
if (typeof endpoint === 'string') {
|
|
285
388
|
({ host } = A2AClient.parseInvite(endpoint));
|
|
286
389
|
} else {
|
|
@@ -289,6 +392,7 @@ class A2AClient {
|
|
|
289
392
|
|
|
290
393
|
const { protocol, hostname, port } = resolveProtocolAndPort(host);
|
|
291
394
|
|
|
395
|
+
// A2A-54: no retry for status — read-only probe, not a stateful operation
|
|
292
396
|
return new Promise((resolve, reject) => {
|
|
293
397
|
const req = protocol.request({
|
|
294
398
|
hostname,
|
|
@@ -297,9 +401,8 @@ class A2AClient {
|
|
|
297
401
|
method: 'GET',
|
|
298
402
|
timeout: 5000
|
|
299
403
|
}, (res) => {
|
|
300
|
-
|
|
301
|
-
res
|
|
302
|
-
res.on('end', () => {
|
|
404
|
+
// A2A-54: size-capped response accumulation
|
|
405
|
+
handleSizeCappedResponse(res, resolve, reject, (data) => {
|
|
303
406
|
try {
|
|
304
407
|
resolve(JSON.parse(data));
|
|
305
408
|
} catch {
|
|
@@ -308,7 +411,7 @@ class A2AClient {
|
|
|
308
411
|
});
|
|
309
412
|
});
|
|
310
413
|
|
|
311
|
-
req.on('error', (e) => reject(new A2AError('network_error', e.message)));
|
|
414
|
+
req.on('error', (e) => reject(new A2AError('network_error', e.code ? `${e.code}: ${e.message}` : e.message)));
|
|
312
415
|
req.on('timeout', () => {
|
|
313
416
|
req.destroy();
|
|
314
417
|
reject(new A2AError('timeout', 'Request timed out'));
|
|
@@ -327,4 +430,13 @@ class A2AError extends Error {
|
|
|
327
430
|
}
|
|
328
431
|
}
|
|
329
432
|
|
|
330
|
-
|
|
433
|
+
// A2A-54: export internals for testing (splitHostPort, resolveProtocolAndPort, constants)
|
|
434
|
+
module.exports = {
|
|
435
|
+
A2AClient,
|
|
436
|
+
A2AError,
|
|
437
|
+
_splitHostPort: splitHostPort,
|
|
438
|
+
_resolveProtocolAndPort: resolveProtocolAndPort,
|
|
439
|
+
_MAX_RESPONSE_BYTES: MAX_RESPONSE_BYTES,
|
|
440
|
+
_RETRYABLE_CODES: RETRYABLE_CODES,
|
|
441
|
+
_RETRY_DELAYS: RETRY_DELAYS
|
|
442
|
+
};
|