@nelsonlongxiang/dsh-open-a2a-net 0.5.13
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 +115 -0
- package/README_zh.md +80 -0
- package/cordis.patch.yml +21 -0
- package/images/image-01.png +0 -0
- package/images/image-02.png +0 -0
- package/lib/a2a-client.js +166 -0
- package/lib/card.js +159 -0
- package/lib/client/A2aControl.js +239 -0
- package/lib/client/index.js +35 -0
- package/lib/client/locales.js +55 -0
- package/lib/client.js +756 -0
- package/lib/client.js.map +1 -0
- package/lib/group-store.js +131 -0
- package/lib/index.js +1485 -0
- package/lib/invariant.js +16 -0
- package/lib/joined-store.js +98 -0
- package/lib/peer-store.js +169 -0
- package/lib/types/a2a-client.d.ts +73 -0
- package/lib/types/card.d.ts +54 -0
- package/lib/types/client/A2aControl.d.ts +64 -0
- package/lib/types/client/index.d.ts +27 -0
- package/lib/types/client/locales.d.ts +7 -0
- package/lib/types/client.d.ts +73 -0
- package/lib/types/group-store.d.ts +58 -0
- package/lib/types/index.d.ts +113 -0
- package/lib/types/invariant.d.ts +8 -0
- package/lib/types/joined-store.d.ts +61 -0
- package/lib/types/peer-store.d.ts +91 -0
- package/lib/types/types.d.ts +97 -0
- package/lib/types/zone.d.ts +37 -0
- package/lib/types.js +5 -0
- package/lib/zone.js +55 -0
- package/package.json +157 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A2A node extension (decentralized topology): registers the `a2a_teams` and
|
|
3
|
+
* `a2a_route` model tools over verified peer agent cards and direct peer
|
|
4
|
+
* routes, publishes this node's card and joined session teams, and dispatches
|
|
5
|
+
* inbound direct routes into live agent sessions.
|
|
6
|
+
* @module @nelsonlongxiang/dsh-open-a2a-net
|
|
7
|
+
*/
|
|
8
|
+
import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { networkInterfaces } from 'node:os';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths';
|
|
13
|
+
// The package own version, read from package.json at load time so the
|
|
14
|
+
// panel never drifts from the manifest the installer actually resolved.
|
|
15
|
+
const PLUGIN_VERSION = String(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
|
|
16
|
+
import { signCard } from "./card.js";
|
|
17
|
+
import { GroupStore } from "./group-store.js";
|
|
18
|
+
import { JoinedSessions } from "./joined-store.js";
|
|
19
|
+
import { PeerStore } from "./peer-store.js";
|
|
20
|
+
import { resolveZone } from "./zone.js";
|
|
21
|
+
import s from '@deepseek-ai/schemastery';
|
|
22
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
23
|
+
import { MessageId } from '@deepseek-ai/dsh-llm';
|
|
24
|
+
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
25
|
+
import { A2aClient } from "./a2a-client.js";
|
|
26
|
+
export { A2aClient } from "./a2a-client.js";
|
|
27
|
+
export const name = 'a2a';
|
|
28
|
+
export const inject = ['tools', 'timer'];
|
|
29
|
+
/** Schemastery configuration for the A2A client plugin row. */
|
|
30
|
+
export const Config = s.object({
|
|
31
|
+
apiKey: s.string().default(''),
|
|
32
|
+
session: s.string().default(''),
|
|
33
|
+
team: s.string().default('dsh'),
|
|
34
|
+
announce: s.boolean().default(false),
|
|
35
|
+
agentName: s.string().default('DeepSeek Harness A2A node'),
|
|
36
|
+
peers: s.array(s.string()).default([]),
|
|
37
|
+
delegates: s.array(s.object({ name: s.string(), url: s.string(), publicKey: s.string().default('') })).default([]),
|
|
38
|
+
dshHome: s.string().default(''),
|
|
39
|
+
sessionNodes: s.boolean().default(true),
|
|
40
|
+
wakeJoinedOnBoot: s.boolean().default(false),
|
|
41
|
+
cardTtlMs: s.number().default(172_800_000),
|
|
42
|
+
flushTimeoutMs: s.number().default(300_000),
|
|
43
|
+
routeTimeoutMs: s.number().default(1_800_000),
|
|
44
|
+
});
|
|
45
|
+
/** Model-facing text for one route result. */
|
|
46
|
+
function renderRoute(_args, value) {
|
|
47
|
+
const result = value;
|
|
48
|
+
const text = result.ok
|
|
49
|
+
? [
|
|
50
|
+
`Team ${result.team} replied (task ${result.task_id}, status ${result.task_status}, context ${result.context_id}):`,
|
|
51
|
+
result.reply,
|
|
52
|
+
].join('\n')
|
|
53
|
+
: `A2A route failed${result.code === undefined ? '' : ` (code ${String(result.code)})`}: ${result.error}`;
|
|
54
|
+
return [{ type: 'text', text }];
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Register the outbound peer tools, the direct-route endpoint, and (when
|
|
58
|
+
* composed) the announced card and session-node control surface.
|
|
59
|
+
* @param ctx - registrant context carrying the tool registry and timer.
|
|
60
|
+
* @param config - deployment's A2A node facts.
|
|
61
|
+
*/
|
|
62
|
+
export function apply(ctx, config) {
|
|
63
|
+
const logger = ctx.logger('a2a');
|
|
64
|
+
// The production seams: Node's globals. Tests inject their own A2aClient
|
|
65
|
+
// seams through src/a2a-client.ts directly. During fiber teardown the timer
|
|
66
|
+
// service is already gone; a timer armed then belongs to nobody, so the
|
|
67
|
+
// arming fails closed (callback never fires).
|
|
68
|
+
const schedule = (callback, delayMs) => {
|
|
69
|
+
try {
|
|
70
|
+
return ctx.timer.timeout(callback, delayMs);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
/* v8 ignore next 2 -- reachable only when the timer service is disposed
|
|
74
|
+
mid-flight; the client owns no teardown-time arming site anymore, so
|
|
75
|
+
no test can drive this without re-adding a teardown unregister. */
|
|
76
|
+
return () => { };
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const fetchImpl = (url, init) => globalThis.fetch(url, init);
|
|
80
|
+
/** Constant-time secret comparison: hash both sides to equal-length digests. */
|
|
81
|
+
function secretEqual(a, b) {
|
|
82
|
+
const digestA = createHash('sha256').update(a).digest();
|
|
83
|
+
const digestB = createHash('sha256').update(b).digest();
|
|
84
|
+
return timingSafeEqual(digestA, digestB);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Same-origin / loopback trust for the control routes when no API key is
|
|
88
|
+
* configured (the sidebar's browser fetches carry no secret): a
|
|
89
|
+
* same-origin or user-initiated navigational request passes, an Origin
|
|
90
|
+
* matching the Host passes, and any loopback caller passes. A cross-site
|
|
91
|
+
* page is rejected even when it reaches a loopback bind (the web-app
|
|
92
|
+
* restart-control trust rule).
|
|
93
|
+
* @param req - the inbound control request.
|
|
94
|
+
* @returns whether the caller is trusted without a key.
|
|
95
|
+
*/
|
|
96
|
+
function controlTrustedWithoutKey(req) {
|
|
97
|
+
const site = req.headers['sec-fetch-site'];
|
|
98
|
+
if (site === 'same-origin' || site === 'none')
|
|
99
|
+
return true;
|
|
100
|
+
const origin = req.headers.origin;
|
|
101
|
+
const host = req.headers.host;
|
|
102
|
+
if (typeof origin === 'string' && typeof host === 'string' && origin === `http://${host}`)
|
|
103
|
+
return true;
|
|
104
|
+
const remote = req.socket.remoteAddress;
|
|
105
|
+
return remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Control-route authorization. With `apiKey` set, the `X-API-Key` header
|
|
109
|
+
* (or `api_key` query, for non-browser clients) must match in constant
|
|
110
|
+
* time. With no key, the same-origin/loopback trust applies — the explicit
|
|
111
|
+
* exposure assumption: an empty-key deployment accepts control from its
|
|
112
|
+
* own page and from loopback, and from nothing else.
|
|
113
|
+
* @param req - the inbound control request.
|
|
114
|
+
* @returns whether the request may drive the control routes.
|
|
115
|
+
*/
|
|
116
|
+
function controlAuthorized(req) {
|
|
117
|
+
if (config.apiKey !== '') {
|
|
118
|
+
const presented = String(req.headers['x-api-key'] ?? new URL(req.url ?? '/', 'http://x').searchParams.get('api_key') ?? '');
|
|
119
|
+
return secretEqual(presented, config.apiKey);
|
|
120
|
+
}
|
|
121
|
+
return controlTrustedWithoutKey(req);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Answer an unauthorized control request: 401 names the missing key,
|
|
125
|
+
* 403 names the untrusted origin.
|
|
126
|
+
* @param req - the inbound request.
|
|
127
|
+
* @param res - the response.
|
|
128
|
+
*/
|
|
129
|
+
function rejectControl(req, res) {
|
|
130
|
+
void req;
|
|
131
|
+
const code = config.apiKey !== '' ? 401 : 403;
|
|
132
|
+
const payload = JSON.stringify({ error: config.apiKey !== '' ? 'unauthorized: X-API-Key required' : 'forbidden: untrusted origin' });
|
|
133
|
+
res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
134
|
+
res.end(payload);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Wrap one control handler with the authorization guard.
|
|
138
|
+
* @param handler - the route's inner handler.
|
|
139
|
+
* @returns the guarded handler.
|
|
140
|
+
*/
|
|
141
|
+
function controlRoute(handler) {
|
|
142
|
+
return (req, res) => {
|
|
143
|
+
if (!controlAuthorized(req)) {
|
|
144
|
+
rejectControl(req, res);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
handler(req, res);
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Read one JSON object body and hand it to the handler; malformed input
|
|
152
|
+
* answers 400. Bounded like the direct endpoint's reader.
|
|
153
|
+
* @param req - the inbound request.
|
|
154
|
+
* @param res - the response for the malformed-body rejection.
|
|
155
|
+
* @param use - receives the parsed body.
|
|
156
|
+
*/
|
|
157
|
+
function readJsonBody(req, res, use) {
|
|
158
|
+
const chunks = [];
|
|
159
|
+
let size = 0;
|
|
160
|
+
req.on('data', (chunk) => {
|
|
161
|
+
size += chunk.length;
|
|
162
|
+
if (size > 10_000) {
|
|
163
|
+
req.destroy();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
chunks.push(chunk);
|
|
167
|
+
});
|
|
168
|
+
req.on('end', () => {
|
|
169
|
+
let body;
|
|
170
|
+
try {
|
|
171
|
+
body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
const payload = JSON.stringify({ error: 'malformed body' });
|
|
175
|
+
res.writeHead(400, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
176
|
+
res.end(payload);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
use(body);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The web server is a sibling row, not a declared injection, so in a Loader
|
|
184
|
+
* tree it can activate after this row: the optional read waits for the tree
|
|
185
|
+
* to settle (immediate in a hand-built context with no Loader). The
|
|
186
|
+
* callback runs at most once and never after this fiber's teardown.
|
|
187
|
+
* @param use - receives the webServer service when the settled tree has one.
|
|
188
|
+
* @param onAbsent - runs when the settled tree has no webServer.
|
|
189
|
+
*/
|
|
190
|
+
function whenWebServerSettled(use, onAbsent) {
|
|
191
|
+
const read = () => {
|
|
192
|
+
const webServer = ctx.get('webServer');
|
|
193
|
+
if (webServer !== undefined)
|
|
194
|
+
use(webServer);
|
|
195
|
+
else
|
|
196
|
+
onAbsent?.();
|
|
197
|
+
};
|
|
198
|
+
const settled = ctx.get('loader')?.await();
|
|
199
|
+
if (settled === undefined)
|
|
200
|
+
read();
|
|
201
|
+
else
|
|
202
|
+
void settled.then(() => {
|
|
203
|
+
if (ctx.fiber.uid !== null)
|
|
204
|
+
read();
|
|
205
|
+
}, () => {
|
|
206
|
+
// A failed boot never mounts the web server row; the absent path stays
|
|
207
|
+
// silent so a dying tree logs its own failure once.
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
// Slice 2: the configured peer list is a seed set; referral URLs learned
|
|
211
|
+
// from fetched cards join a bounded, quality-scored store persisted under
|
|
212
|
+
// the same home as the node key.
|
|
213
|
+
const home = config.dshHome === '' ? resolveDshHome() : resolveDshHome(config.dshHome);
|
|
214
|
+
const peerStore = new PeerStore(config.peers, join(home, 'a2a', 'peers.json'));
|
|
215
|
+
// Slice 3: an unset session derives from a per-home node id, and the
|
|
216
|
+
// configured delegations become signed zone records on every card.
|
|
217
|
+
const session = config.session === '' ? deriveSession(home) : config.session;
|
|
218
|
+
const records = config.delegates.map(delegate => ({
|
|
219
|
+
type: 'delegate',
|
|
220
|
+
name: delegate.name,
|
|
221
|
+
url: delegate.url,
|
|
222
|
+
...(delegate.publicKey !== '' ? { publicKey: delegate.publicKey } : {}),
|
|
223
|
+
}));
|
|
224
|
+
const client = new A2aClient({
|
|
225
|
+
apiKey: config.apiKey,
|
|
226
|
+
sessionId: session,
|
|
227
|
+
schedule,
|
|
228
|
+
fetch: fetchImpl,
|
|
229
|
+
});
|
|
230
|
+
// Persisted join intent: a join is a user gesture over a durable session,
|
|
231
|
+
// but the node it mounts dies with the session's Agent — page reloads and
|
|
232
|
+
// host restarts silently dropped every join. The store remounts the node
|
|
233
|
+
// whenever the Agent comes back, until the user leaves.
|
|
234
|
+
const joinedSessions = new JoinedSessions(join(home, 'a2a', 'joined.json'));
|
|
235
|
+
const ACTIVITY_CAP = 20;
|
|
236
|
+
const recentActivity = [];
|
|
237
|
+
const inFlightRoutes = new Map();
|
|
238
|
+
let inFlightSeq = 0;
|
|
239
|
+
const beginRoute = (team, peer) => {
|
|
240
|
+
const id = ++inFlightSeq;
|
|
241
|
+
inFlightRoutes.set(id, { id, team, peer, startedAt: Date.now() });
|
|
242
|
+
return id;
|
|
243
|
+
};
|
|
244
|
+
const endRoute = (id) => { inFlightRoutes.delete(id); };
|
|
245
|
+
const recordActivity = (dir, team, peer, ok) => {
|
|
246
|
+
recentActivity.push({ ts: Date.now(), dir, team, peer, ok });
|
|
247
|
+
if (recentActivity.length > ACTIVITY_CAP)
|
|
248
|
+
recentActivity.splice(0, recentActivity.length - ACTIVITY_CAP);
|
|
249
|
+
};
|
|
250
|
+
/**
|
|
251
|
+
* This host's primary LAN IPv4 (first non-internal address), computed once:
|
|
252
|
+
* a node fact the card and state route publish so peers and operators can
|
|
253
|
+
* tell machines apart in a same-team fleet. Empty when no external address
|
|
254
|
+
* exists (loopback-only deployments).
|
|
255
|
+
*/
|
|
256
|
+
const lanIp = (() => {
|
|
257
|
+
for (const entries of Object.values(networkInterfaces())) {
|
|
258
|
+
for (const entry of entries ?? []) {
|
|
259
|
+
if (entry.family === 'IPv4' && !entry.internal)
|
|
260
|
+
return entry.address;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return '';
|
|
264
|
+
})();
|
|
265
|
+
// Session groups: user-named buckets for the panel's session listing,
|
|
266
|
+
// persisted beside the join intents so they survive restarts.
|
|
267
|
+
const groupStore = new GroupStore(join(home, 'a2a', 'groups.json'));
|
|
268
|
+
// Session nodes: every joined top-level session is its own addressable
|
|
269
|
+
// team (label `<session>-<agentId8>`, team `<team>/<agentId8>`). Joining
|
|
270
|
+
// is a local fact: the entry dispatches `/a2a/direct` routes and rides
|
|
271
|
+
// the announced card's unsigned sessionTeams listing.
|
|
272
|
+
const sessionNodes = new Map();
|
|
273
|
+
// Live top-level agents: the join surface's candidates and the cold-row
|
|
274
|
+
// complement (apply scope — the direct-route wake below reads it too).
|
|
275
|
+
const liveRoots = new Map();
|
|
276
|
+
/**
|
|
277
|
+
* The api gateway's wake face, when composed: materializing a persisted
|
|
278
|
+
* session's agent (log replay plus composed preset world) is web-app
|
|
279
|
+
* knowledge, so both wake paths ride the one service that owns it.
|
|
280
|
+
*/
|
|
281
|
+
const materialize = (id) => {
|
|
282
|
+
const apiProxy = ctx.get('apiProxy');
|
|
283
|
+
if (apiProxy === undefined)
|
|
284
|
+
return undefined;
|
|
285
|
+
return apiProxy.materializeSession(SessionId(id));
|
|
286
|
+
};
|
|
287
|
+
/**
|
|
288
|
+
* Wake one cold joined session on demand — the route-triggered half:
|
|
289
|
+
* the join consented to network reachability, so a route addressed to
|
|
290
|
+
* its team pays the wake rather than failing.
|
|
291
|
+
* @param team - the routed team name.
|
|
292
|
+
* @returns a promise resolving with the woken agent, or undefined when
|
|
293
|
+
* the team names no cold joined session or no wake face is composed.
|
|
294
|
+
*/
|
|
295
|
+
const wakeColdTeam = (team) => {
|
|
296
|
+
const id = joinedSessions.list().find(entry => !liveRoots.has(entry) && `${config.team}/${id8(entry)}` === team);
|
|
297
|
+
if (id === undefined)
|
|
298
|
+
return undefined;
|
|
299
|
+
// An archived session never wakes: archive is closure, not sleep.
|
|
300
|
+
if (archivedSessionFilter()?.(id) === true)
|
|
301
|
+
return undefined;
|
|
302
|
+
return materialize(id);
|
|
303
|
+
};
|
|
304
|
+
/**
|
|
305
|
+
* The workspace registry's archived set, when composed. Archiving is a
|
|
306
|
+
* registry state — the session store stays — so this list is the one
|
|
307
|
+
* authoritative archive signal. Read structurally: the registry is an
|
|
308
|
+
* optional neighbor, not a dependency.
|
|
309
|
+
*/
|
|
310
|
+
function archivedSessionFilter() {
|
|
311
|
+
const registry = ctx.get('workspaceRegistry');
|
|
312
|
+
if (registry === undefined || !Array.isArray(registry.archivedSessionIds))
|
|
313
|
+
return undefined;
|
|
314
|
+
const archived = new Set(registry.archivedSessionIds.map(String));
|
|
315
|
+
return (id) => archived.has(id);
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Archived sessions leave the node network: archive is closure, so the
|
|
319
|
+
* persisted join intent goes, a mounted live node unmounts, and the next
|
|
320
|
+
* listing stops advertising the team. Runs at boot settlement and on every
|
|
321
|
+
* state read — the panel's poll makes a mid-session archive disappear
|
|
322
|
+
* within one poll interval.
|
|
323
|
+
*/
|
|
324
|
+
const pruneArchivedJoins = () => {
|
|
325
|
+
const isArchived = archivedSessionFilter();
|
|
326
|
+
if (isArchived === undefined)
|
|
327
|
+
return;
|
|
328
|
+
for (const id of joinedSessions.list()) {
|
|
329
|
+
if (!isArchived(id))
|
|
330
|
+
continue;
|
|
331
|
+
sessionNodes.delete(id);
|
|
332
|
+
joinedSessions.remove(id);
|
|
333
|
+
logger.info(`a2a: archived session ${id8(id)} left the node network`);
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
/**
|
|
337
|
+
* The short id suffix session-node labels and teams key on. Web agents
|
|
338
|
+
* carry `SessionId`-branded ids whose first 8 characters are the literal
|
|
339
|
+
* `session-` prefix — every node would share one team — so the brand
|
|
340
|
+
* strips before the slice. Imported sessions (`import-<uuid>`) keep the
|
|
341
|
+
* `import-` prefix plus the uuid's first 8 hex chars: a bare 8-char slice
|
|
342
|
+
* would collapse the whole import family into 16 possible ids (the uuid's
|
|
343
|
+
* 8th char), which collides as soon as several sessions are imported.
|
|
344
|
+
*/
|
|
345
|
+
function id8(id) {
|
|
346
|
+
const bare = id.replace(/^session-/, '');
|
|
347
|
+
const imported = bare.match(/^import-([0-9a-fA-F]+)/);
|
|
348
|
+
if (imported !== null)
|
|
349
|
+
return `import-${imported[1].slice(0, 8)}`;
|
|
350
|
+
return bare.slice(0, 8);
|
|
351
|
+
}
|
|
352
|
+
/** The 8-char suffix of one agent's id. */
|
|
353
|
+
function agentId8(agent) {
|
|
354
|
+
return id8(String(agent.id));
|
|
355
|
+
}
|
|
356
|
+
/** The session node's wire label. */
|
|
357
|
+
function sessionLabelOf(agent) {
|
|
358
|
+
return `${session}-${agentId8(agent)}`;
|
|
359
|
+
}
|
|
360
|
+
/** The session node's team name. */
|
|
361
|
+
function sessionTeamOf(agent) {
|
|
362
|
+
return `${config.team}/${agentId8(agent)}`;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Resolve the live agent a session-node team names, when that node exists.
|
|
366
|
+
* @param team - the routed team name.
|
|
367
|
+
* @returns the node's agent, or undefined for any other team.
|
|
368
|
+
*/
|
|
369
|
+
function resolveAgentForTeam(team) {
|
|
370
|
+
const prefix = `${config.team}/`;
|
|
371
|
+
if (!team.startsWith(prefix))
|
|
372
|
+
return undefined;
|
|
373
|
+
const suffix = team.slice(prefix.length);
|
|
374
|
+
return [...sessionNodes.values()].find(agent => agentId8(agent) === suffix);
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Human-facing node facts for one session: the session title (fallback:
|
|
378
|
+
* the node label), a one-line recent-activity excerpt, and the session's
|
|
379
|
+
* working directory (from the durable session header — a storage fact
|
|
380
|
+
* read fresh, never baked into any prompt).
|
|
381
|
+
* @param agent - the session the facts describe.
|
|
382
|
+
* @returns the name/description/workspace triple the card and state route serve.
|
|
383
|
+
*/
|
|
384
|
+
function nodeMetadataOf(agent) {
|
|
385
|
+
const title = ctx.get('sessionTitle')?.get(agent.session)?.title;
|
|
386
|
+
const cwd = agent.session.header?.cwd;
|
|
387
|
+
return {
|
|
388
|
+
name: title !== undefined && title !== '' ? title : sessionLabelOf(agent),
|
|
389
|
+
description: recentActivityOf(agent),
|
|
390
|
+
...(cwd !== undefined && cwd !== '' ? { workspace: cwd } : {}),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* One line of recent activity: the newest user or assistant text,
|
|
395
|
+
* whitespace-collapsed and truncated.
|
|
396
|
+
* @param agent - the session whose event log is scanned.
|
|
397
|
+
* @returns the excerpt, or a placeholder for a fresh session.
|
|
398
|
+
*/
|
|
399
|
+
function recentActivityOf(agent) {
|
|
400
|
+
const events = agent.session.events;
|
|
401
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
402
|
+
const event = events[index];
|
|
403
|
+
if (event === undefined)
|
|
404
|
+
continue;
|
|
405
|
+
const blocks = event.type === 'user/message'
|
|
406
|
+
? event.data.content
|
|
407
|
+
: event.type === 'assistant/message'
|
|
408
|
+
? event.data.message.content
|
|
409
|
+
: undefined;
|
|
410
|
+
const text = blocks
|
|
411
|
+
?.filter((block) => block.type === 'text')
|
|
412
|
+
.map(block => block.text)
|
|
413
|
+
.join(' ')
|
|
414
|
+
.replace(/\s+/g, ' ')
|
|
415
|
+
.trim();
|
|
416
|
+
if (text !== undefined && text !== '')
|
|
417
|
+
return text.slice(0, 160);
|
|
418
|
+
}
|
|
419
|
+
return 'no activity yet';
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Join one live top-level session: its team dispatches direct routes and
|
|
423
|
+
* rides the announced card from this moment.
|
|
424
|
+
* @param agent - the session to join.
|
|
425
|
+
*/
|
|
426
|
+
function mountSessionNode(agent) {
|
|
427
|
+
sessionNodes.set(String(agent.id), agent);
|
|
428
|
+
}
|
|
429
|
+
if (config.sessionNodes) {
|
|
430
|
+
// Track live top-level sessions for the join surface; joining is
|
|
431
|
+
// explicit (the sidebar control), never automatic. A throwing
|
|
432
|
+
// agent/created listener vetoes publication, so tracking is strictly
|
|
433
|
+
// side-effect-only.
|
|
434
|
+
ctx.on('agent/created', ({ agent }) => {
|
|
435
|
+
const agents = ctx.get('agents');
|
|
436
|
+
if (agents === undefined || !agents.roots().some(root => root.id === agent.id))
|
|
437
|
+
return;
|
|
438
|
+
liveRoots.set(String(agent.id), agent);
|
|
439
|
+
if (joinedSessions.has(String(agent.id)))
|
|
440
|
+
mountSessionNode(agent);
|
|
441
|
+
});
|
|
442
|
+
ctx.on('agent/disposed', ({ agent }) => {
|
|
443
|
+
liveRoots.delete(String(agent.id));
|
|
444
|
+
// Disposal unmounts the runtime node; the persisted intent stays for
|
|
445
|
+
// the remount above (leave is the only intent remover).
|
|
446
|
+
sessionNodes.delete(String(agent.id));
|
|
447
|
+
});
|
|
448
|
+
for (const agent of ctx.get('agents')?.roots() ?? []) {
|
|
449
|
+
liveRoots.set(String(agent.id), agent);
|
|
450
|
+
if (joinedSessions.has(String(agent.id)))
|
|
451
|
+
mountSessionNode(agent);
|
|
452
|
+
}
|
|
453
|
+
{
|
|
454
|
+
// Archive pruning and boot wake both ride the loader tree's
|
|
455
|
+
// settlement: the workspace registry and the api gateway may activate
|
|
456
|
+
// after this row in the same tree, and an apply-time snapshot would
|
|
457
|
+
// see neither. Archived intents prune first — an archived session
|
|
458
|
+
// must never wake — and pruning runs even with the wake off.
|
|
459
|
+
const pruneThenWake = () => {
|
|
460
|
+
pruneArchivedJoins();
|
|
461
|
+
if (!config.wakeJoinedOnBoot)
|
|
462
|
+
return;
|
|
463
|
+
if (ctx.get('apiProxy') === undefined) {
|
|
464
|
+
logger.warn('wakeJoinedOnBoot is on but no api gateway is composed; cold joined sessions stay asleep');
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
for (const id of joinedSessions.list()) {
|
|
468
|
+
if (liveRoots.has(id))
|
|
469
|
+
continue;
|
|
470
|
+
// The remount listener joins the node the moment the woken agent
|
|
471
|
+
// publishes; a failed wake keeps the cold row and its intent.
|
|
472
|
+
materialize(id)?.catch((error) => {
|
|
473
|
+
logger.warn(`boot wake of ${id} failed: ${String(error)}`);
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
const settled = ctx.get('loader')?.await();
|
|
478
|
+
if (settled === undefined)
|
|
479
|
+
pruneThenWake();
|
|
480
|
+
else
|
|
481
|
+
void settled.then(() => {
|
|
482
|
+
if (ctx.fiber.uid !== null)
|
|
483
|
+
pruneThenWake();
|
|
484
|
+
}, () => {
|
|
485
|
+
// A failed boot never wakes anything; the dying tree logs its own
|
|
486
|
+
// failure once.
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
// Peer-side rows for the panel, grouped there by origin: the sweep is
|
|
490
|
+
// real network work, so a shared 5s cache serves the 2s panel poll
|
|
491
|
+
// without hammering peers. The state read answers synchronously from the
|
|
492
|
+
// cache (never awaiting inside the handler) and kicks a background
|
|
493
|
+
// refresh when stale — the next poll picks the fresh rows up.
|
|
494
|
+
let remoteRowsCache;
|
|
495
|
+
const refreshRemoteRows = () => {
|
|
496
|
+
const now = Date.now();
|
|
497
|
+
if (remoteRowsCache !== undefined && now - remoteRowsCache.at < 5_000)
|
|
498
|
+
return;
|
|
499
|
+
void listDirectoryTeams(false)
|
|
500
|
+
.then(all => {
|
|
501
|
+
remoteRowsCache = {
|
|
502
|
+
at: now,
|
|
503
|
+
rows: all
|
|
504
|
+
.filter(row => row.local !== true)
|
|
505
|
+
.map(row => ({ team: row.team, name: row.name, ...(row.origin !== undefined ? { origin: row.origin } : {}), ...(row.workspace !== undefined ? { workspace: row.workspace } : {}) })),
|
|
506
|
+
};
|
|
507
|
+
})
|
|
508
|
+
.catch(() => { });
|
|
509
|
+
};
|
|
510
|
+
whenWebServerSettled((webServer) => {
|
|
511
|
+
ctx.effect(() => webServer.register({
|
|
512
|
+
kind: 'exact',
|
|
513
|
+
path: '/__dsh_a2a/state',
|
|
514
|
+
handler: controlRoute((_req, res) => {
|
|
515
|
+
void (async () => {
|
|
516
|
+
// The panel polls this route: pruning here makes a mid-session
|
|
517
|
+
// archive leave the network within one poll interval, no restart
|
|
518
|
+
// required.
|
|
519
|
+
pruneArchivedJoins();
|
|
520
|
+
const assignments = groupStore.all();
|
|
521
|
+
const groupOf = (id) => {
|
|
522
|
+
const name = assignments[id];
|
|
523
|
+
return name === undefined ? undefined : name;
|
|
524
|
+
};
|
|
525
|
+
// Archived sessions are closed: the GUI hides them everywhere, so
|
|
526
|
+
// the panel must not re-list them as join surface rows — joined
|
|
527
|
+
// or not. (The prune above already took their join intents; this
|
|
528
|
+
// covers never-joined live roots and any intent a restart left.)
|
|
529
|
+
const isArchived = archivedSessionFilter();
|
|
530
|
+
const sessions = [...liveRoots.values()]
|
|
531
|
+
.filter(agent => isArchived?.(String(agent.id)) !== true)
|
|
532
|
+
.map(agent => ({
|
|
533
|
+
id: String(agent.id),
|
|
534
|
+
label: sessionLabelOf(agent),
|
|
535
|
+
team: sessionTeamOf(agent),
|
|
536
|
+
...nodeMetadataOf(agent),
|
|
537
|
+
joined: sessionNodes.has(String(agent.id)),
|
|
538
|
+
live: true,
|
|
539
|
+
...(groupOf(String(agent.id)) !== undefined ? { group: groupOf(String(agent.id)) } : {}),
|
|
540
|
+
}));
|
|
541
|
+
// Cold joined sessions: a remembered intent whose Agent is not
|
|
542
|
+
// back yet (host restarted, session not opened). The row carries
|
|
543
|
+
// no facts — the Web client merges the session title from its
|
|
544
|
+
// own list — and wakes through the normal session-open path,
|
|
545
|
+
// after which the remount above joins it automatically.
|
|
546
|
+
const persistence = ctx.get('sessionPersistence');
|
|
547
|
+
if (persistence !== undefined) {
|
|
548
|
+
const persisted = new Set((await persistence.list()).map(header => String(header.id)));
|
|
549
|
+
for (const id of joinedSessions.list()) {
|
|
550
|
+
if (liveRoots.has(id) || !persisted.has(id))
|
|
551
|
+
continue;
|
|
552
|
+
sessions.push({
|
|
553
|
+
id,
|
|
554
|
+
label: `${session}-${id8(id)}`,
|
|
555
|
+
team: `${config.team}/${id8(id)}`,
|
|
556
|
+
joined: true,
|
|
557
|
+
live: false,
|
|
558
|
+
...(groupOf(id) !== undefined ? { group: groupOf(id) } : {}),
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
const body = JSON.stringify({
|
|
563
|
+
nodes: true,
|
|
564
|
+
version: PLUGIN_VERSION,
|
|
565
|
+
sessions,
|
|
566
|
+
groups: groupStore.list(),
|
|
567
|
+
host: lanIp === '' ? {} : { lanIp },
|
|
568
|
+
peers: peerStore.list().map(url => ({ url, score: peerStore.score(url) })),
|
|
569
|
+
remote: (refreshRemoteRows(), remoteRowsCache?.rows ?? []),
|
|
570
|
+
activity: recentActivity.slice(),
|
|
571
|
+
inFlight: [...inFlightRoutes.values()].map(route => ({
|
|
572
|
+
team: route.team,
|
|
573
|
+
peer: route.peer,
|
|
574
|
+
startedAt: route.startedAt,
|
|
575
|
+
})),
|
|
576
|
+
});
|
|
577
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
|
|
578
|
+
res.end(body);
|
|
579
|
+
})();
|
|
580
|
+
}),
|
|
581
|
+
}), 'a2a: session-node state route');
|
|
582
|
+
ctx.effect(() => webServer.register({
|
|
583
|
+
kind: 'exact',
|
|
584
|
+
path: '/__dsh_a2a/join',
|
|
585
|
+
handler: controlRoute((req, res) => {
|
|
586
|
+
readJsonBody(req, res, (body) => {
|
|
587
|
+
const agent = typeof body.id === 'string' ? liveRoots.get(body.id) : undefined;
|
|
588
|
+
if (agent === undefined) {
|
|
589
|
+
const payload = JSON.stringify({ error: 'no live session with that id' });
|
|
590
|
+
res.writeHead(404, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
591
|
+
res.end(payload);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
mountSessionNode(agent);
|
|
595
|
+
joinedSessions.add(String(agent.id));
|
|
596
|
+
const payload = JSON.stringify({ id: body.id, label: sessionLabelOf(agent), team: sessionTeamOf(agent) });
|
|
597
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
598
|
+
res.end(payload);
|
|
599
|
+
});
|
|
600
|
+
}),
|
|
601
|
+
}), 'a2a: session-node join route');
|
|
602
|
+
ctx.effect(() => webServer.register({
|
|
603
|
+
kind: 'exact',
|
|
604
|
+
path: '/__dsh_a2a/leave',
|
|
605
|
+
handler: controlRoute((req, res) => {
|
|
606
|
+
readJsonBody(req, res, (body) => {
|
|
607
|
+
const id = typeof body.id === 'string' ? body.id : '';
|
|
608
|
+
if (id !== '')
|
|
609
|
+
sessionNodes.delete(id);
|
|
610
|
+
joinedSessions.remove(id);
|
|
611
|
+
const payload = JSON.stringify({ id });
|
|
612
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
613
|
+
res.end(payload);
|
|
614
|
+
});
|
|
615
|
+
}),
|
|
616
|
+
}), 'a2a: session-node leave route');
|
|
617
|
+
// Groups control: create/remove named groups and assign sessions to
|
|
618
|
+
// them (an empty name unassigns). Same guard as join/leave — the
|
|
619
|
+
// group taxonomy enumerates session ids, so it stays behind the key.
|
|
620
|
+
ctx.effect(() => webServer.register({
|
|
621
|
+
kind: 'exact',
|
|
622
|
+
path: '/__dsh_a2a/groups',
|
|
623
|
+
handler: controlRoute((req, res) => {
|
|
624
|
+
readJsonBody(req, res, (body) => {
|
|
625
|
+
const action = body.action;
|
|
626
|
+
let payload;
|
|
627
|
+
if (action === 'create') {
|
|
628
|
+
const name = typeof body.name === 'string' ? body.name : '';
|
|
629
|
+
const stored = groupStore.create(name);
|
|
630
|
+
payload = JSON.stringify(stored === undefined ? { ok: false, error: 'invalid name or group cap reached' } : { ok: true, name: stored, groups: groupStore.list() });
|
|
631
|
+
}
|
|
632
|
+
else if (action === 'remove') {
|
|
633
|
+
const name = typeof body.name === 'string' ? body.name : '';
|
|
634
|
+
payload = JSON.stringify({ ok: groupStore.remove(name), groups: groupStore.list() });
|
|
635
|
+
}
|
|
636
|
+
else if (action === 'assign') {
|
|
637
|
+
const id = typeof body.id === 'string' ? body.id : '';
|
|
638
|
+
const name = typeof body.name === 'string' ? body.name : undefined;
|
|
639
|
+
if (id === '' || name === undefined) {
|
|
640
|
+
payload = JSON.stringify({ ok: false, error: 'id and name are required' });
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
payload = JSON.stringify({ ok: groupStore.assign(id, name), groups: groupStore.list() });
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
else {
|
|
647
|
+
payload = JSON.stringify({ ok: false, error: 'unknown action' });
|
|
648
|
+
}
|
|
649
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
650
|
+
res.end(payload);
|
|
651
|
+
});
|
|
652
|
+
}),
|
|
653
|
+
}), 'a2a: session-group route');
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
if (config.announce) {
|
|
657
|
+
whenWebServerSettled((webServer) => {
|
|
658
|
+
const privateKey = loadOrCreateNodeKey(join(home, 'a2a', 'node-key.pem'));
|
|
659
|
+
const cardCore = () => ({
|
|
660
|
+
name: config.agentName,
|
|
661
|
+
session,
|
|
662
|
+
team: config.team,
|
|
663
|
+
// async: wait:false is honored (steer + delivered, no final hold);
|
|
664
|
+
// the signed capability turns async dialing into a deterministic
|
|
665
|
+
// check instead of a timeout race against pre-0.5.2 peers.
|
|
666
|
+
capabilities: { route: true, async: true },
|
|
667
|
+
expiresAt: Date.now() + config.cardTtlMs,
|
|
668
|
+
...(records.length > 0 ? { records: [...records] } : {}),
|
|
669
|
+
});
|
|
670
|
+
let currentCard = signCard(cardCore(), privateKey);
|
|
671
|
+
// Re-sign at TTL/4: four publication opportunities per validity window
|
|
672
|
+
// tolerate three lost refreshes (GNUnet zonemaster cadence).
|
|
673
|
+
const refresh = ctx.timer.interval(() => {
|
|
674
|
+
currentCard = signCard(cardCore(), privateKey);
|
|
675
|
+
}, Math.max(1, Math.floor(config.cardTtlMs / 4)));
|
|
676
|
+
ctx.effect(() => webServer.register({
|
|
677
|
+
kind: 'exact',
|
|
678
|
+
path: '/.well-known/agent-card.json',
|
|
679
|
+
handler: (_req, res) => {
|
|
680
|
+
// The unsigned peers and sessionTeams fields carry this node's
|
|
681
|
+
// current peer set and joined sessions at serve time (between
|
|
682
|
+
// re-signs), so each card read spreads the publisher's latest
|
|
683
|
+
// view of the network.
|
|
684
|
+
const sessionTeams = [...sessionNodes.values()].map(agent => ({ team: sessionTeamOf(agent), ...nodeMetadataOf(agent) }));
|
|
685
|
+
const body = JSON.stringify({ ...currentCard, peers: peerStore.list(), ...(sessionTeams.length > 0 ? { sessionTeams } : {}), ...(lanIp !== '' ? { lanIp } : {}), description: `A2A node exposing team ${config.team}` });
|
|
686
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
|
|
687
|
+
res.end(body);
|
|
688
|
+
},
|
|
689
|
+
}), 'a2a: agent card route');
|
|
690
|
+
ctx.effect(() => refresh, 'a2a: card refresh timer');
|
|
691
|
+
}, () => {
|
|
692
|
+
logger.warn('announce is on but no web server is composed; the agent card is not published');
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Resolve the live agent for inbound steering: the initiator when
|
|
697
|
+
* recoverable, else the first root.
|
|
698
|
+
* @returns the agent, or undefined when no agent is live.
|
|
699
|
+
*/
|
|
700
|
+
function liveAgent() {
|
|
701
|
+
const agents = ctx.get('agents');
|
|
702
|
+
if (agents === undefined)
|
|
703
|
+
return undefined;
|
|
704
|
+
try {
|
|
705
|
+
return agents.requireInitiator();
|
|
706
|
+
}
|
|
707
|
+
catch {
|
|
708
|
+
const roots = agents.roots();
|
|
709
|
+
return roots.length > 0 ? roots[0] : undefined;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Steer one relay-form user message into the agent; the caller owns
|
|
714
|
+
* rejection handling.
|
|
715
|
+
* @param agent - the steering target.
|
|
716
|
+
* @param text - the fully formatted relay message.
|
|
717
|
+
*/
|
|
718
|
+
function steerRelay(agent, text) {
|
|
719
|
+
agent.steer({
|
|
720
|
+
id: MessageId(`a2a-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`),
|
|
721
|
+
role: 'user',
|
|
722
|
+
content: [{ type: 'text', text }],
|
|
723
|
+
source: { kind: 'plugin', plugin: 'a2a', form: 'relay' },
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Register one final-reply waiter for the agent and arm its flush timeout.
|
|
728
|
+
* @param agent - the agent whose next assistant message answers the waiter.
|
|
729
|
+
* @param answer - how the waiter's reply is delivered.
|
|
730
|
+
* @returns the registered waiter (for callers that may retract it).
|
|
731
|
+
*/
|
|
732
|
+
function registerFinalWaiter(agent, answer) {
|
|
733
|
+
const key = String(agent.id);
|
|
734
|
+
const waiter = { answer, sinceEvents: agent.session.events.length };
|
|
735
|
+
waiter.timeoutDisposer = armFlushTimeout(key, waiter);
|
|
736
|
+
pendingFinals.set(key, [...(pendingFinals.get(key) ?? []), waiter]);
|
|
737
|
+
return waiter;
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Steer one direct route request into a live agent and resolve with its
|
|
741
|
+
* final reply (final semantics always: the HTTP caller has no other
|
|
742
|
+
* channel to learn the reply).
|
|
743
|
+
* @param taskId - the correlation key the steered receipt header carries.
|
|
744
|
+
*/
|
|
745
|
+
function routeIntoAgent(team, message, caller, taskId) {
|
|
746
|
+
const agent = resolveAgentForTeam(team);
|
|
747
|
+
if (agent !== undefined) {
|
|
748
|
+
return routeIntoAgentFor(agent, team, message, caller, taskId);
|
|
749
|
+
}
|
|
750
|
+
// Wake-on-route: a cold joined team materializes on demand, then steers.
|
|
751
|
+
const woken = wakeColdTeam(team);
|
|
752
|
+
if (woken !== undefined) {
|
|
753
|
+
return woken.then(agent => routeIntoAgentFor(agent, team, message, caller, taskId), (error) => ({ ok: false, error: `waking the session for team "${team}" failed: ${String(error)}` }));
|
|
754
|
+
}
|
|
755
|
+
// Only the bare process team falls back to the live (initiator) agent.
|
|
756
|
+
// A session-node-shaped team that misses is an error, never a silent
|
|
757
|
+
// redirect: a mistyped or stale short id must not reach another session
|
|
758
|
+
// through the initiator fallback.
|
|
759
|
+
if (team === config.team) {
|
|
760
|
+
const live = liveAgent();
|
|
761
|
+
if (live !== undefined)
|
|
762
|
+
return routeIntoAgentFor(live, team, message, caller, taskId);
|
|
763
|
+
return Promise.resolve({ ok: false, error: 'No live DSH agent is available to accept this message.' });
|
|
764
|
+
}
|
|
765
|
+
return Promise.resolve({ ok: false, error: `No live DSH session node accepts team "${team}" and no cold joined session matches it.` });
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Steer one direct route request into one named agent and resolve with its
|
|
769
|
+
* final reply.
|
|
770
|
+
* @param agent - the session-node agent the request addresses.
|
|
771
|
+
* @param team - team name the caller routed to (diagnostics).
|
|
772
|
+
* @param message - request text.
|
|
773
|
+
* @param caller - caller label for the steered prefix.
|
|
774
|
+
* @returns the agent's final reply, or the explicit failure.
|
|
775
|
+
*/
|
|
776
|
+
function routeIntoAgentFor(agent, team, message, caller, taskId) {
|
|
777
|
+
return new Promise((resolve) => {
|
|
778
|
+
const waiter = registerFinalWaiter(agent, (text) => {
|
|
779
|
+
resolve({ ok: true, reply: text });
|
|
780
|
+
});
|
|
781
|
+
// The header names the sender first: `caller` is the routing node's
|
|
782
|
+
// own label (the session that issued the route), while `team` is this
|
|
783
|
+
// request's target — showing the target as "remote team" hid the
|
|
784
|
+
// actual origin behind the receiver's own team name. The task id, when
|
|
785
|
+
// the caller supplied one, rides the header so an "[A2A receipt] task
|
|
786
|
+
// <id>" answer correlates with the caller's own route result.
|
|
787
|
+
const from = caller === '' ? 'an unknown node' : caller;
|
|
788
|
+
const taskPrefix = taskId === undefined ? '' : `(task ${taskId}) `;
|
|
789
|
+
const text = `[A2A direct] ${taskPrefix}from "${from}" (routed to ${team}) sent:
|
|
790
|
+
|
|
791
|
+
${message}`;
|
|
792
|
+
try {
|
|
793
|
+
steerRelay(agent, text);
|
|
794
|
+
}
|
|
795
|
+
catch (error) {
|
|
796
|
+
// The waiter was registered above, so the key always holds it; the
|
|
797
|
+
// map-of-arrays stays a plain read.
|
|
798
|
+
const key = String(agent.id);
|
|
799
|
+
const entries = pendingFinals.get(key);
|
|
800
|
+
const kept = entries.filter(entry => entry !== waiter);
|
|
801
|
+
if (kept.length === 0)
|
|
802
|
+
pendingFinals.delete(key);
|
|
803
|
+
else
|
|
804
|
+
pendingFinals.set(key, kept);
|
|
805
|
+
waiter.timeoutDisposer?.();
|
|
806
|
+
resolve({ ok: false, error: `The DSH session rejected the message: ${String(error)}` });
|
|
807
|
+
}
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
whenWebServerSettled((webServer) => {
|
|
811
|
+
ctx.effect(() => webServer.register({
|
|
812
|
+
kind: 'exact',
|
|
813
|
+
path: '/a2a/direct',
|
|
814
|
+
handler: (req, res) => {
|
|
815
|
+
const chunks = [];
|
|
816
|
+
let size = 0;
|
|
817
|
+
req.on('data', (chunk) => {
|
|
818
|
+
size += chunk.length;
|
|
819
|
+
if (size > 1_000_000) {
|
|
820
|
+
req.destroy();
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
chunks.push(chunk);
|
|
824
|
+
});
|
|
825
|
+
/* v8 ignore next 6 -- client aborts surface as 'close' via the shared
|
|
826
|
+
webServer's per-request guard; a raw req 'error' needs a malformed
|
|
827
|
+
HTTP stream the webServer rejects before this handler exists */
|
|
828
|
+
req.on('error', () => {
|
|
829
|
+
if (!res.headersSent) {
|
|
830
|
+
res.writeHead(400);
|
|
831
|
+
res.end();
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
req.on('end', () => {
|
|
835
|
+
let body;
|
|
836
|
+
try {
|
|
837
|
+
body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
838
|
+
}
|
|
839
|
+
catch {
|
|
840
|
+
const payload = JSON.stringify({ error: 'malformed body', code: -32000 });
|
|
841
|
+
res.writeHead(400, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
842
|
+
res.end(payload);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const team = typeof body.team === 'string' ? body.team : '';
|
|
846
|
+
const message = typeof body.message === 'string' ? body.message : '';
|
|
847
|
+
const caller = typeof body.caller_session === 'string' ? body.caller_session : '';
|
|
848
|
+
const noWait = body.wait === false;
|
|
849
|
+
const contextId = typeof body.context_id === 'string' && body.context_id !== '' ? body.context_id : `ctx-${Math.random().toString(16).slice(2, 10)}`;
|
|
850
|
+
// The caller-born task id (idempotency key): echo it when present
|
|
851
|
+
// so the receipt header correlates with the caller's own result;
|
|
852
|
+
// pre-0.5.3 callers carry none and this node mints one.
|
|
853
|
+
const taskId = typeof body.task_id === 'string' && body.task_id !== '' ? body.task_id : `direct-${Math.random().toString(16).slice(2, 10)}`;
|
|
854
|
+
if (team === '' || message === '') {
|
|
855
|
+
const payload = JSON.stringify({ error: 'team and message are required', code: -32000 });
|
|
856
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
857
|
+
res.end(payload);
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
// wait:false delivers without the final-reply hold: steer resolves
|
|
861
|
+
// synchronously inside routeIntoAgentFor, so by the time the agent
|
|
862
|
+
// lookup settles the message is already in — answer delivered and
|
|
863
|
+
// let the receipt contract carry the reply.
|
|
864
|
+
if (noWait) {
|
|
865
|
+
const agent = resolveAgentForTeam(team);
|
|
866
|
+
const woken = agent !== undefined ? undefined : wakeColdTeam(team);
|
|
867
|
+
const deliver = (target) => {
|
|
868
|
+
const from = caller === '' ? 'an unknown node' : caller;
|
|
869
|
+
try {
|
|
870
|
+
// The receipt header carries the task id: the target echoes
|
|
871
|
+
// it verbatim in "[A2A receipt] task <id> ...", closing the
|
|
872
|
+
// correlation loop with the caller's own route result.
|
|
873
|
+
steerRelay(target, `[A2A direct] (task ${taskId}) from "${from}" (routed to ${team}) sent:\n\n${message}`);
|
|
874
|
+
recordActivity('in', team, caller, true);
|
|
875
|
+
const payload = JSON.stringify({
|
|
876
|
+
routed: true,
|
|
877
|
+
delivered: true,
|
|
878
|
+
team,
|
|
879
|
+
session,
|
|
880
|
+
task_id: taskId,
|
|
881
|
+
context_id: contextId,
|
|
882
|
+
task_status: 'TASK_STATE_DELIVERED',
|
|
883
|
+
artifacts: [],
|
|
884
|
+
});
|
|
885
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
886
|
+
res.end(payload);
|
|
887
|
+
}
|
|
888
|
+
catch (error) {
|
|
889
|
+
recordActivity('in', team, caller, false);
|
|
890
|
+
const payload = JSON.stringify({ error: `The DSH session rejected the message: ${String(error)}`, code: -32000, team, task_id: taskId, task_status: 'TASK_STATE_FAILED' });
|
|
891
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
892
|
+
res.end(payload);
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
if (agent !== undefined) {
|
|
896
|
+
deliver(agent);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
if (woken !== undefined) {
|
|
900
|
+
void woken.then(deliver, (error) => {
|
|
901
|
+
recordActivity('in', team, caller, false);
|
|
902
|
+
const payload = JSON.stringify({ error: `waking the session for team "${team}" failed: ${String(error)}`, code: -32000, team, task_status: 'TASK_STATE_FAILED' });
|
|
903
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
904
|
+
res.end(payload);
|
|
905
|
+
});
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
if (team === config.team) {
|
|
909
|
+
const live = liveAgent();
|
|
910
|
+
if (live !== undefined) {
|
|
911
|
+
deliver(live);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
const payload = JSON.stringify({ error: `No live DSH session node accepts team "${team}" and no cold joined session matches it.`, code: -32000, team, task_status: 'TASK_STATE_FAILED' });
|
|
916
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
917
|
+
res.end(payload);
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
void routeIntoAgent(team, message, caller, taskId).then((outcome) => {
|
|
921
|
+
recordActivity('in', team, caller, outcome.ok);
|
|
922
|
+
const payload = outcome.ok
|
|
923
|
+
? JSON.stringify({
|
|
924
|
+
routed: true,
|
|
925
|
+
team,
|
|
926
|
+
session,
|
|
927
|
+
result: { text: outcome.reply },
|
|
928
|
+
task_id: taskId,
|
|
929
|
+
context_id: contextId,
|
|
930
|
+
task_status: 'TASK_STATE_COMPLETED',
|
|
931
|
+
artifacts: [],
|
|
932
|
+
})
|
|
933
|
+
: JSON.stringify({ error: outcome.error, code: -32000, team, task_id: taskId, task_status: 'TASK_STATE_FAILED' });
|
|
934
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
|
|
935
|
+
res.end(payload);
|
|
936
|
+
});
|
|
937
|
+
});
|
|
938
|
+
},
|
|
939
|
+
}), 'a2a: direct route endpoint');
|
|
940
|
+
});
|
|
941
|
+
/**
|
|
942
|
+
* Fetch one peer's card and move its store entry: the fetch is itself the
|
|
943
|
+
* reachability check — a miss degrades the peer, a verified card raises it
|
|
944
|
+
* and offers its referral list to the store. A peer offered mid-iteration
|
|
945
|
+
* is fetched on a later tool call, so gossip converges across calls.
|
|
946
|
+
* @param peer - base URL of the peer to visit.
|
|
947
|
+
* @returns the verified card, or undefined when the peer is unreachable or serves an invalid card.
|
|
948
|
+
*/
|
|
949
|
+
async function fetchPeerCard(peer) {
|
|
950
|
+
const card = await client.fetchCard(peer);
|
|
951
|
+
if (card === undefined) {
|
|
952
|
+
peerStore.noteFailure(peer);
|
|
953
|
+
return undefined;
|
|
954
|
+
}
|
|
955
|
+
// A self-referral learned from a peer's card (the peer lists this node's
|
|
956
|
+
// URL back at it) must not track the node as its own peer: the node would
|
|
957
|
+
// list its own teams as remote rows and offer its own URL onward. The
|
|
958
|
+
// signed card session is the identity check — no URL guessing.
|
|
959
|
+
if (card.session === session) {
|
|
960
|
+
peerStore.drop(peer);
|
|
961
|
+
return undefined;
|
|
962
|
+
}
|
|
963
|
+
peerStore.noteSuccess(peer);
|
|
964
|
+
for (const referral of card.peers ?? [])
|
|
965
|
+
peerStore.offer(referral);
|
|
966
|
+
return card;
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* One verified-card fetch per zone URL per tool call: the direct pass and
|
|
970
|
+
* every delegated hop share the memo, so each zone is fetched (and scored,
|
|
971
|
+
* and offered referrals) exactly once per call.
|
|
972
|
+
* @returns the memoized card fetch.
|
|
973
|
+
*/
|
|
974
|
+
function memoizedCardFetch() {
|
|
975
|
+
const cache = new Map();
|
|
976
|
+
return async (url) => {
|
|
977
|
+
if (!cache.has(url))
|
|
978
|
+
cache.set(url, await fetchPeerCard(url));
|
|
979
|
+
return cache.get(url);
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
ctx.tools.register(defineTool({
|
|
983
|
+
name: 'a2a_teams',
|
|
984
|
+
description: 'List or search reachable A2A teams: this host\'s own process team and joined session nodes first (marked [this host], '
|
|
985
|
+
+ 'routable over loopback — same-host collaboration needs no peers), then teams across the tracked peer network '
|
|
986
|
+
+ 'with owner label, a recent-activity excerpt, the publishing host (origin: node label + LAN IP — the natural '
|
|
987
|
+
+ 'grouping when a fleet spans machines), and the session\'s working directory when shared. Pass query to filter '
|
|
988
|
+
+ 'by keyword (case-insensitive substring over team name, title, excerpt, origin, or workspace) — searching '
|
|
989
|
+
+ 'discovers one extra referral hop within the call. Call this before a2a_route to pick a target team.',
|
|
990
|
+
parameters: {
|
|
991
|
+
query: { type: 'string', description: 'Optional keyword filter (case-insensitive substring over team/name/description).' },
|
|
992
|
+
},
|
|
993
|
+
output: {
|
|
994
|
+
schema: {
|
|
995
|
+
type: 'object',
|
|
996
|
+
additionalProperties: false,
|
|
997
|
+
properties: {
|
|
998
|
+
ok: { type: 'boolean', required: true },
|
|
999
|
+
query: { type: 'string', description: 'The filter applied, empty when unfiltered.' },
|
|
1000
|
+
teams: {
|
|
1001
|
+
type: 'array',
|
|
1002
|
+
required: true,
|
|
1003
|
+
items: {
|
|
1004
|
+
type: 'object',
|
|
1005
|
+
additionalProperties: false,
|
|
1006
|
+
properties: {
|
|
1007
|
+
team: { type: 'string', required: true },
|
|
1008
|
+
session: { type: 'string', required: true },
|
|
1009
|
+
name: { type: 'string', required: true },
|
|
1010
|
+
description: { type: 'string', required: true },
|
|
1011
|
+
local: { type: 'boolean', description: 'true when the team is served by this host (loopback candidate).' },
|
|
1012
|
+
origin: { type: 'string', description: 'The publishing host (node session label, LAN IP when known) — the natural grouping for fleet rows.' },
|
|
1013
|
+
workspace: { type: 'string', description: 'The session\'s working directory, when shared.' },
|
|
1014
|
+
},
|
|
1015
|
+
},
|
|
1016
|
+
},
|
|
1017
|
+
},
|
|
1018
|
+
},
|
|
1019
|
+
render: (args, value) => [{
|
|
1020
|
+
type: 'text',
|
|
1021
|
+
text: value.teams.length === 0
|
|
1022
|
+
? `No A2A teams${(value.query ?? '') === '' ? '' : ` matching "${String(args.query)}"`} are currently reachable.`
|
|
1023
|
+
: value.teams.map(team => `- ${team.team}${team.local === true ? ' [this host]' : ''}${team.name === '' ? '' : ` (${team.name})`}${team.description === '' ? '' : ` — ${team.description}`}${typeof team.origin === 'string' && team.origin !== '' && team.local !== true ? ` @ ${team.origin}` : ''}${typeof team.workspace === 'string' && team.workspace !== '' ? ` [${team.workspace}]` : ''}`).join('\n'),
|
|
1024
|
+
}],
|
|
1025
|
+
},
|
|
1026
|
+
presentCall: args => ({ card: 'generic', title: args.query === undefined || args.query === '' ? 'List reachable A2A teams' : `Search A2A teams: ${args.query}`, kind: 'other', rawInput: args }),
|
|
1027
|
+
execute: async (args) => {
|
|
1028
|
+
const query = args.query?.trim().toLowerCase() ?? '';
|
|
1029
|
+
const teams = await listDirectoryTeams(query !== '');
|
|
1030
|
+
if (query === '')
|
|
1031
|
+
return { ok: true, query: '', teams };
|
|
1032
|
+
const matches = (team) => team.team.toLowerCase().includes(query) || team.name.toLowerCase().includes(query) || team.description.toLowerCase().includes(query)
|
|
1033
|
+
|| (team.origin ?? '').toLowerCase().includes(query) || (team.workspace ?? '').toLowerCase().includes(query);
|
|
1034
|
+
return { ok: true, query, teams: teams.filter(matches) };
|
|
1035
|
+
},
|
|
1036
|
+
}));
|
|
1037
|
+
async function listDirectoryTeams(expand) {
|
|
1038
|
+
const localOrigin = lanIp === '' ? `${session} [this host]` : `${session} [this host, ${lanIp}]`;
|
|
1039
|
+
const teams = [
|
|
1040
|
+
{ team: config.team, session, name: config.agentName, description: '', local: true, origin: localOrigin },
|
|
1041
|
+
...[...sessionNodes.values()].map(agent => ({
|
|
1042
|
+
team: sessionTeamOf(agent),
|
|
1043
|
+
session,
|
|
1044
|
+
...nodeMetadataOf(agent),
|
|
1045
|
+
local: true,
|
|
1046
|
+
origin: localOrigin,
|
|
1047
|
+
})),
|
|
1048
|
+
];
|
|
1049
|
+
const fetch = memoizedCardFetch();
|
|
1050
|
+
// Collect per peer in store order (concurrent fetches, ordered merge):
|
|
1051
|
+
// the listing's row order follows the peer store's preference order,
|
|
1052
|
+
// independent of fetch completion timing.
|
|
1053
|
+
const collectPeer = async (peer) => {
|
|
1054
|
+
const card = await fetch(peer);
|
|
1055
|
+
if (card === undefined)
|
|
1056
|
+
return [];
|
|
1057
|
+
// The origin fields (publisher session label + LAN IP) are natural
|
|
1058
|
+
// grouping dimensions: a fleet's rows cluster by the host that owns
|
|
1059
|
+
// them, no manual grouping required.
|
|
1060
|
+
const origin = card.lanIp !== undefined ? `${card.session} (${card.lanIp})` : card.session;
|
|
1061
|
+
const rows = [
|
|
1062
|
+
{ team: card.team, session: card.session, name: card.name, description: '', origin },
|
|
1063
|
+
];
|
|
1064
|
+
for (const entry of card.sessionTeams ?? []) {
|
|
1065
|
+
rows.push({ team: entry.team, session: card.session, name: entry.name, description: entry.description, origin, ...(entry.workspace !== undefined ? { workspace: entry.workspace } : {}) });
|
|
1066
|
+
}
|
|
1067
|
+
return rows;
|
|
1068
|
+
};
|
|
1069
|
+
const sweep = async (peers) => {
|
|
1070
|
+
const collected = await Promise.all(peers.map(collectPeer));
|
|
1071
|
+
for (const rows of collected)
|
|
1072
|
+
teams.push(...rows);
|
|
1073
|
+
};
|
|
1074
|
+
const before = peerStore.list();
|
|
1075
|
+
await sweep(before);
|
|
1076
|
+
if (expand) {
|
|
1077
|
+
// One extra hop: only peers the first sweep's referrals newly offered,
|
|
1078
|
+
// collected once each — the memo keeps already-fetched URLs free.
|
|
1079
|
+
const fresh = peerStore.list().filter(peer => !before.includes(peer));
|
|
1080
|
+
if (fresh.length > 0)
|
|
1081
|
+
await sweep(fresh);
|
|
1082
|
+
}
|
|
1083
|
+
return teams;
|
|
1084
|
+
}
|
|
1085
|
+
ctx.tools.register(defineTool({
|
|
1086
|
+
name: 'a2a_route',
|
|
1087
|
+
description: 'Route a message to a remote A2A team on the peer network and await its reply. '
|
|
1088
|
+
+ 'Pick the team with a2a_teams first. Team names also resolve through peers\' signed zone '
|
|
1089
|
+
+ 'delegation records (zone-relative, at most 5 hops). Reuse context_id from a previous reply '
|
|
1090
|
+
+ 'to continue that conversation. The call blocks until the remote team responds, which may take minutes — '
|
|
1091
|
+
+ 'pass async for long-running tasks: delivery returns immediately and the target routes a receipt '
|
|
1092
|
+
+ '(message starting "[A2A receipt] task <task_id> <outcome summary>") back to your team.',
|
|
1093
|
+
parameters: {
|
|
1094
|
+
team: { type: 'string', required: true, description: 'Target team name, from a2a_teams.' },
|
|
1095
|
+
message: { type: 'string', required: true, description: 'The request text to send.' },
|
|
1096
|
+
context_id: { type: 'string', description: 'Context id from a previous a2a_route reply, to continue that conversation.' },
|
|
1097
|
+
async: { type: 'boolean', description: 'Fire-and-forget: deliver and return immediately (delivered:true + task_id) without waiting for the target\'s reply. The target answers by routing a receipt — a message starting "[A2A receipt] task <task_id> <outcome summary>" — back to your team (visible in a2a_status activity), or follow up with the context_id. Prefer async for long-running tasks (minutes to hours).' },
|
|
1098
|
+
},
|
|
1099
|
+
output: {
|
|
1100
|
+
schema: {
|
|
1101
|
+
type: 'object',
|
|
1102
|
+
additionalProperties: true,
|
|
1103
|
+
},
|
|
1104
|
+
render: renderRoute,
|
|
1105
|
+
},
|
|
1106
|
+
timeoutMs: config.routeTimeoutMs,
|
|
1107
|
+
presentCall: args => ({ card: 'generic', title: `A2A route → ${args.team}`, kind: 'other', rawInput: args }),
|
|
1108
|
+
execute: async (args, exec) => {
|
|
1109
|
+
// The caller identity travels as a routable team (the calling session's
|
|
1110
|
+
// node team when it has joined, else the node label), so the receiver
|
|
1111
|
+
// can answer with one a2a_route instead of an unroutable display label.
|
|
1112
|
+
const callerSession = exec.agent === undefined ? undefined
|
|
1113
|
+
: sessionNodes.has(String(exec.agent.id)) ? sessionTeamOf(exec.agent) : sessionLabelOf(exec.agent);
|
|
1114
|
+
const fetch = memoizedCardFetch();
|
|
1115
|
+
// The task id is born at the caller (idempotency key semantics): both
|
|
1116
|
+
// dispatchers carry the one id, the peer request echoes it, and the
|
|
1117
|
+
// steered receipt header names it — so "[A2A receipt] task <id>" from
|
|
1118
|
+
// the target correlates with the route's own result verbatim.
|
|
1119
|
+
const taskId = `direct-${Math.random().toString(16).slice(2, 10)}`;
|
|
1120
|
+
// Candidate order: this host's own teams first (an in-process steer —
|
|
1121
|
+
// cheapest possible and immune to nested-signal aborts), then direct
|
|
1122
|
+
// publishers, then zone delegations, deduplicated by URL.
|
|
1123
|
+
const failures = [];
|
|
1124
|
+
const outcome = await dispatchLocalCandidate(args.team, args.message, callerSession, args.context_id, exec.signal, args.async === true, taskId);
|
|
1125
|
+
if (outcome !== undefined) {
|
|
1126
|
+
if (outcome.ok)
|
|
1127
|
+
return outcome;
|
|
1128
|
+
failures.push(`local: ${outcome.error}`);
|
|
1129
|
+
}
|
|
1130
|
+
const candidates = await directoryPeerCandidates(fetch, args.team, failures);
|
|
1131
|
+
// Failover: try every candidate in order — an offline path falls back
|
|
1132
|
+
// to the next alternate without a caller-visible error; only
|
|
1133
|
+
// exhaustion surfaces, with each candidate's reason.
|
|
1134
|
+
for (const candidate of candidates) {
|
|
1135
|
+
// Capability gate: wait:false only dials peers whose signed card
|
|
1136
|
+
// declares async — against a pre-0.5.2 peer the flag would silently
|
|
1137
|
+
// degrade into a minutes-long blocking hold (an async intent must
|
|
1138
|
+
// never turn into a surprise wait). The memo keeps the card fetch
|
|
1139
|
+
// free after the directory walk.
|
|
1140
|
+
let peerAsync = false;
|
|
1141
|
+
if (args.async === true) {
|
|
1142
|
+
const card = await fetch(candidate);
|
|
1143
|
+
const caps = card?.capabilities;
|
|
1144
|
+
peerAsync = card !== undefined && caps?.async === true;
|
|
1145
|
+
}
|
|
1146
|
+
const result = await dispatchPeerCandidate(candidate, args.team, args.message, args.context_id, exec.signal, callerSession, peerAsync, taskId);
|
|
1147
|
+
if (result.ok || exec.signal.aborted) {
|
|
1148
|
+
if (args.async === true && !peerAsync && result.ok) {
|
|
1149
|
+
return { ...result, reply: `${result.reply}\n(Note: the peer does not advertise async; the call waited synchronously.)` };
|
|
1150
|
+
}
|
|
1151
|
+
return result;
|
|
1152
|
+
}
|
|
1153
|
+
failures.push(`${candidate}: ${result.error}`);
|
|
1154
|
+
}
|
|
1155
|
+
if (failures.length > 0)
|
|
1156
|
+
logger.warn(`route to team "${args.team}" exhausted its candidates: ${failures.join('; ')}`);
|
|
1157
|
+
return {
|
|
1158
|
+
ok: false,
|
|
1159
|
+
error: candidates.length === 0
|
|
1160
|
+
? `team "${args.team}" is not published by any configured peer${failures.length === 0 ? '' : ` (unresolved delegations: ${failures.join('; ')})`}`
|
|
1161
|
+
: `team "${args.team}" failed on every candidate: ${failures.join('; ')}`,
|
|
1162
|
+
code: -32004,
|
|
1163
|
+
};
|
|
1164
|
+
},
|
|
1165
|
+
}));
|
|
1166
|
+
/**
|
|
1167
|
+
* The route dispatcher's local half: same-host candidates dial in-process,
|
|
1168
|
+
* not over loopback HTTP — a loopback fetch nests the final wait inside the
|
|
1169
|
+
* caller's own tool signal, and when that turn budget aborts, the abort
|
|
1170
|
+
* surfaces as the peer's failure even though delivery succeeded. The turn
|
|
1171
|
+
* signal still races the wait; on abort the honest result reports delivery
|
|
1172
|
+
* without a reply. Results use the canonical A2aRouteOk shape — the wire
|
|
1173
|
+
* route shape renders as "failed: undefined" through the tool renderer.
|
|
1174
|
+
* @param asyncMode - deliver without waiting: the steer fires, the
|
|
1175
|
+
* registered waiter still flushes (harmlessly) into the settled promise,
|
|
1176
|
+
* and the caller gets the delivered shape with the receipt contract.
|
|
1177
|
+
* @returns the canonical result, or undefined when the team is not local.
|
|
1178
|
+
*/
|
|
1179
|
+
async function dispatchLocalCandidate(team, message, callerSession, contextId, signal, asyncMode = false, taskIdFromCaller) {
|
|
1180
|
+
const webServer = ctx.get('webServer');
|
|
1181
|
+
const teamIsLocal = team === config.team
|
|
1182
|
+
|| resolveAgentForTeam(team) !== undefined
|
|
1183
|
+
|| joinedSessions.list().some(id => `${config.team}/${id8(id)}` === team);
|
|
1184
|
+
if (!teamIsLocal || webServer === undefined)
|
|
1185
|
+
return undefined;
|
|
1186
|
+
const taskId = taskIdFromCaller ?? `direct-${Math.random().toString(16).slice(2, 10)}`;
|
|
1187
|
+
const flight = beginRoute(team, 'local');
|
|
1188
|
+
if (asyncMode) {
|
|
1189
|
+
// Delivery means the message is IN, not merely enqueued: a cold team
|
|
1190
|
+
// materializes first (the wake settles, or fails honestly), then the
|
|
1191
|
+
// steer fires, then delivered answers. The receipt header carries the
|
|
1192
|
+
// task id so the target can echo it back verbatim.
|
|
1193
|
+
const agent = resolveAgentForTeam(team) ?? (team === config.team ? liveAgent() : undefined);
|
|
1194
|
+
const woken = agent !== undefined ? Promise.resolve(agent) : wakeColdTeam(team);
|
|
1195
|
+
if (woken === undefined) {
|
|
1196
|
+
endRoute(flight);
|
|
1197
|
+
return { ok: false, error: `No live DSH session node accepts team "${team}" and no cold joined session matches it.`, code: -32000 };
|
|
1198
|
+
}
|
|
1199
|
+
try {
|
|
1200
|
+
const target = await woken;
|
|
1201
|
+
steerRelay(target, `[A2A direct] (task ${taskId}) from "${callerSession ?? session}" (routed to ${team}) sent:\n\n${message}`);
|
|
1202
|
+
endRoute(flight);
|
|
1203
|
+
recordActivity('out', team, 'local', true);
|
|
1204
|
+
return {
|
|
1205
|
+
ok: true,
|
|
1206
|
+
team,
|
|
1207
|
+
reply: `Delivered to ${team} (async). The target routes a receipt — a message starting "[A2A receipt] task ${taskId} <outcome summary>" — back to your team when done; watch a2a_status activity or follow up with the context id.`,
|
|
1208
|
+
task_id: taskId,
|
|
1209
|
+
context_id: contextId ?? `ctx-${Math.random().toString(16).slice(2, 10)}`,
|
|
1210
|
+
task_status: 'TASK_STATE_DELIVERED',
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
catch (error) {
|
|
1214
|
+
endRoute(flight);
|
|
1215
|
+
recordActivity('out', team, 'local', false);
|
|
1216
|
+
return { ok: false, error: `waking or steering the session for team "${team}" failed: ${String(error)}`, code: -32000 };
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
const wait = routeIntoAgent(team, message, callerSession ?? session, taskId);
|
|
1220
|
+
const aborted = new Promise((_, reject) => {
|
|
1221
|
+
signal.addEventListener('abort', () => { reject(new Error('aborted')); }, { once: true });
|
|
1222
|
+
});
|
|
1223
|
+
// A reply wait without a deadline parks the caller's whole turn forever
|
|
1224
|
+
// when the target session answers on its own (slow or never) cadence. The
|
|
1225
|
+
// deadline turns that into the same honest delivered shape the abort path
|
|
1226
|
+
// returns: the receipt contract carries the follow-up, the caller keeps
|
|
1227
|
+
// moving.
|
|
1228
|
+
const REPLY_WAIT_MS = 180_000;
|
|
1229
|
+
const expired = new Promise((_, reject) => {
|
|
1230
|
+
const timer = setTimeout(() => { reject(new Error('reply-wait-timeout')); }, REPLY_WAIT_MS);
|
|
1231
|
+
timer.unref?.();
|
|
1232
|
+
const done = () => { clearTimeout(timer); signal.removeEventListener('abort', onAbort); };
|
|
1233
|
+
const onAbort = () => done();
|
|
1234
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
1235
|
+
wait.then(done, done);
|
|
1236
|
+
});
|
|
1237
|
+
let outcome;
|
|
1238
|
+
try {
|
|
1239
|
+
outcome = await Promise.race([wait, aborted, expired]);
|
|
1240
|
+
}
|
|
1241
|
+
catch (error) {
|
|
1242
|
+
if (String(error).includes('reply-wait-timeout')) {
|
|
1243
|
+
endRoute(flight);
|
|
1244
|
+
recordActivity('out', team, 'local', true);
|
|
1245
|
+
return {
|
|
1246
|
+
ok: true,
|
|
1247
|
+
team,
|
|
1248
|
+
reply: `The message was delivered to ${team}, but no reply arrived within ${Math.round(REPLY_WAIT_MS / 1000)}s (the target session answers on its own cadence). It routes a receipt, a message starting "[A2A receipt] task ${taskId}", back to your team when done; check a2a_status activity or follow up with the context id.`,
|
|
1249
|
+
task_id: taskId,
|
|
1250
|
+
context_id: contextId ?? `ctx-${Math.random().toString(16).slice(2, 10)}`,
|
|
1251
|
+
task_status: 'TASK_STATE_DELIVERED',
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
endRoute(flight);
|
|
1255
|
+
recordActivity('out', team, 'local', true);
|
|
1256
|
+
return {
|
|
1257
|
+
ok: true,
|
|
1258
|
+
team,
|
|
1259
|
+
reply: `The message was delivered to ${team}, but the wait for its reply was aborted (the target session answers on its own cadence; route again with the context id).`,
|
|
1260
|
+
task_id: taskId,
|
|
1261
|
+
context_id: contextId ?? `ctx-${Math.random().toString(16).slice(2, 10)}`,
|
|
1262
|
+
task_status: 'TASK_STATE_ABORTED_WAIT',
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
endRoute(flight);
|
|
1266
|
+
recordActivity('out', team, 'local', outcome.ok);
|
|
1267
|
+
if (outcome.ok) {
|
|
1268
|
+
return {
|
|
1269
|
+
ok: true,
|
|
1270
|
+
team,
|
|
1271
|
+
reply: outcome.reply,
|
|
1272
|
+
task_id: taskId,
|
|
1273
|
+
context_id: contextId ?? `ctx-${Math.random().toString(16).slice(2, 10)}`,
|
|
1274
|
+
task_status: 'TASK_STATE_COMPLETED',
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
return { ok: false, error: outcome.error, code: -32000 };
|
|
1278
|
+
}
|
|
1279
|
+
/**
|
|
1280
|
+
* The route dispatcher's peer half: one candidate dial with the in-flight
|
|
1281
|
+
* registration and activity-ring record the panel reads. Async mode sends
|
|
1282
|
+
* wait:false — the peer steers and answers delivered immediately (its
|
|
1283
|
+
* 0.5.2+ handler), so cross-host long tasks ride the receipt contract
|
|
1284
|
+
* exactly like same-host ones; older peers that ignore the flag simply
|
|
1285
|
+
* keep the blocking wait (graceful degradation).
|
|
1286
|
+
*/
|
|
1287
|
+
async function dispatchPeerCandidate(url, team, message, contextId, signal, callerSession, asyncMode = false, taskIdFromCaller) {
|
|
1288
|
+
const flight = beginRoute(team, url);
|
|
1289
|
+
const result = await client.routeDirect(url, team, message, contextId, signal, callerSession, asyncMode, taskIdFromCaller);
|
|
1290
|
+
endRoute(flight);
|
|
1291
|
+
recordActivity('out', team, url, result.ok);
|
|
1292
|
+
return result;
|
|
1293
|
+
}
|
|
1294
|
+
/**
|
|
1295
|
+
* The directory's candidate walk for one team: direct publishers first,
|
|
1296
|
+
* then every tracked zone's delegation resolutions (cycle/depth/key-binding
|
|
1297
|
+
* failures are configuration bugs: closed, logged, and surfaced into
|
|
1298
|
+
* {@link failures}), deduplicated by URL.
|
|
1299
|
+
*/
|
|
1300
|
+
async function directoryPeerCandidates(fetch, team, failures) {
|
|
1301
|
+
const candidates = [];
|
|
1302
|
+
for (const peer of peerStore.list()) {
|
|
1303
|
+
const card = await fetch(peer);
|
|
1304
|
+
if (card === undefined)
|
|
1305
|
+
continue;
|
|
1306
|
+
if (card.team === team || (card.sessionTeams ?? []).some(entry => entry.team === team))
|
|
1307
|
+
candidates.push(peer);
|
|
1308
|
+
}
|
|
1309
|
+
for (const peer of peerStore.list()) {
|
|
1310
|
+
const outcome = await resolveZone(fetch, peer, team);
|
|
1311
|
+
if (outcome.ok) {
|
|
1312
|
+
if (!candidates.includes(outcome.url))
|
|
1313
|
+
candidates.push(outcome.url);
|
|
1314
|
+
continue;
|
|
1315
|
+
}
|
|
1316
|
+
if (outcome.reason === 'not-found' || outcome.reason === 'unreachable')
|
|
1317
|
+
continue;
|
|
1318
|
+
failures.push(`${peer} ${outcome.reason}: ${outcome.detail}`);
|
|
1319
|
+
}
|
|
1320
|
+
return candidates;
|
|
1321
|
+
}
|
|
1322
|
+
ctx.tools.register(defineTool({
|
|
1323
|
+
name: 'a2a_status',
|
|
1324
|
+
description: 'Read-only A2A network health: the tracked peer fleet with quality scores, routes currently in flight '
|
|
1325
|
+
+ '(who is waiting on whom), and the recent routing activity ring (inbound and outbound outcomes). '
|
|
1326
|
+
+ 'Use it to observe an ongoing collaboration or diagnose delivery instead of re-routing.',
|
|
1327
|
+
parameters: {},
|
|
1328
|
+
output: {
|
|
1329
|
+
schema: {
|
|
1330
|
+
type: 'object',
|
|
1331
|
+
additionalProperties: false,
|
|
1332
|
+
properties: {
|
|
1333
|
+
ok: { type: 'boolean', required: true },
|
|
1334
|
+
peers: {
|
|
1335
|
+
type: 'array',
|
|
1336
|
+
required: true,
|
|
1337
|
+
items: {
|
|
1338
|
+
type: 'object',
|
|
1339
|
+
additionalProperties: false,
|
|
1340
|
+
properties: {
|
|
1341
|
+
url: { type: 'string', required: true },
|
|
1342
|
+
score: { type: 'number' },
|
|
1343
|
+
},
|
|
1344
|
+
},
|
|
1345
|
+
},
|
|
1346
|
+
inFlight: {
|
|
1347
|
+
type: 'array',
|
|
1348
|
+
required: true,
|
|
1349
|
+
items: {
|
|
1350
|
+
type: 'object',
|
|
1351
|
+
additionalProperties: false,
|
|
1352
|
+
properties: {
|
|
1353
|
+
team: { type: 'string', required: true },
|
|
1354
|
+
peer: { type: 'string', required: true },
|
|
1355
|
+
startedAt: { type: 'number', required: true },
|
|
1356
|
+
},
|
|
1357
|
+
},
|
|
1358
|
+
},
|
|
1359
|
+
activity: {
|
|
1360
|
+
type: 'array',
|
|
1361
|
+
required: true,
|
|
1362
|
+
items: {
|
|
1363
|
+
type: 'object',
|
|
1364
|
+
additionalProperties: false,
|
|
1365
|
+
properties: {
|
|
1366
|
+
ts: { type: 'number', required: true },
|
|
1367
|
+
dir: { type: 'string', required: true },
|
|
1368
|
+
team: { type: 'string', required: true },
|
|
1369
|
+
peer: { type: 'string', required: true },
|
|
1370
|
+
ok: { type: 'boolean', required: true },
|
|
1371
|
+
},
|
|
1372
|
+
},
|
|
1373
|
+
},
|
|
1374
|
+
},
|
|
1375
|
+
},
|
|
1376
|
+
render: (_args, value) => [{
|
|
1377
|
+
type: 'text',
|
|
1378
|
+
text: [
|
|
1379
|
+
...(value.inFlight.length === 0 ? [] : ['In flight:']),
|
|
1380
|
+
...value.inFlight.map((route) => ` → ${route.team} via ${route.peer === 'local' ? 'this host' : route.peer}`),
|
|
1381
|
+
`Peers (${String(value.peers.length)}):`,
|
|
1382
|
+
...value.peers.map((peer) => ` ${peer.url}${typeof peer.score === 'number' ? ` (score ${String(Math.round(peer.score))})` : ''}`),
|
|
1383
|
+
...(value.activity.length === 0 ? [] : ['Recent routes:']),
|
|
1384
|
+
...[...value.activity].reverse().slice(0, 10).map((entry) => ` ${entry.dir === 'in' ? '←' : '→'} ${entry.team} ${entry.ok ? 'ok' : 'failed'}`),
|
|
1385
|
+
].join('\n'),
|
|
1386
|
+
}],
|
|
1387
|
+
},
|
|
1388
|
+
presentCall: () => ({ card: 'generic', title: 'A2A network status', kind: 'other', rawInput: null }),
|
|
1389
|
+
execute: async () => {
|
|
1390
|
+
return {
|
|
1391
|
+
ok: true,
|
|
1392
|
+
peers: peerStore.list().map(url => ({ url, score: peerStore.score(url) })),
|
|
1393
|
+
inFlight: [...inFlightRoutes.values()].map(route => ({ team: route.team, peer: route.peer, startedAt: route.startedAt })),
|
|
1394
|
+
activity: recentActivity.slice(),
|
|
1395
|
+
};
|
|
1396
|
+
},
|
|
1397
|
+
}));
|
|
1398
|
+
const pendingFinals = new Map();
|
|
1399
|
+
const armFlushTimeout = (agentId, waiter) => {
|
|
1400
|
+
return ctx.timer.timeout(() => {
|
|
1401
|
+
const entries = pendingFinals.get(agentId);
|
|
1402
|
+
if (entries === undefined)
|
|
1403
|
+
return;
|
|
1404
|
+
const kept = entries.filter(entry => entry !== waiter);
|
|
1405
|
+
if (kept.length === 0)
|
|
1406
|
+
pendingFinals.delete(agentId);
|
|
1407
|
+
else
|
|
1408
|
+
pendingFinals.set(agentId, kept);
|
|
1409
|
+
waiter.answer('The DSH session produced no final reply within the configured window.');
|
|
1410
|
+
}, config.flushTimeoutMs);
|
|
1411
|
+
};
|
|
1412
|
+
function flushFinals(agentId) {
|
|
1413
|
+
const entries = pendingFinals.get(agentId);
|
|
1414
|
+
if (entries === undefined || entries.length === 0)
|
|
1415
|
+
return;
|
|
1416
|
+
const agent = ctx.get('agents')?.get(SessionId(agentId));
|
|
1417
|
+
if (agent === undefined) {
|
|
1418
|
+
for (const entry of entries) {
|
|
1419
|
+
entry.timeoutDisposer?.();
|
|
1420
|
+
entry.answer('The target DSH session is no longer live.');
|
|
1421
|
+
}
|
|
1422
|
+
pendingFinals.delete(agentId);
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
const events = agent.session.events;
|
|
1426
|
+
const floor = Math.min(...entries.map(entry => entry.sinceEvents));
|
|
1427
|
+
let reply = '';
|
|
1428
|
+
for (let index = events.length - 1; index >= floor; index--) {
|
|
1429
|
+
const event = events[index];
|
|
1430
|
+
if (event === undefined || event.type !== 'assistant/message')
|
|
1431
|
+
continue;
|
|
1432
|
+
reply = event.data.message.content
|
|
1433
|
+
.filter((block) => block.type === 'text')
|
|
1434
|
+
.map(block => block.text)
|
|
1435
|
+
.join('\n');
|
|
1436
|
+
break;
|
|
1437
|
+
}
|
|
1438
|
+
if (reply !== '') {
|
|
1439
|
+
for (const entry of entries)
|
|
1440
|
+
entry.answer(reply);
|
|
1441
|
+
pendingFinals.delete(agentId);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
// Every inbound route is a direct route (final semantics), so the idle
|
|
1445
|
+
// flush listens unconditionally — an empty pendingFinals map makes it a
|
|
1446
|
+
// no-op. Node facts need no refresh here: the card serves them fresh at
|
|
1447
|
+
// read time.
|
|
1448
|
+
ctx.on('agent/status', (payload) => {
|
|
1449
|
+
if (payload.status === 'idle')
|
|
1450
|
+
flushFinals(String(payload.agent.id));
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Load the node's Ed25519 private key, generating and persisting it on first
|
|
1455
|
+
* use. The key IS the node's card identity: a persisted key keeps signatures
|
|
1456
|
+
* verifiable across restarts, which is why storage lands with the announce
|
|
1457
|
+
* config surface rather than after it.
|
|
1458
|
+
* @param path - absolute PEM path (`<dsh-home>/a2a/node-key.pem`).
|
|
1459
|
+
* @returns the private key used to sign every published card.
|
|
1460
|
+
*/
|
|
1461
|
+
function loadOrCreateNodeKey(path) {
|
|
1462
|
+
if (existsSync(path)) {
|
|
1463
|
+
return createPrivateKey(readFileSync(path, 'utf8'));
|
|
1464
|
+
}
|
|
1465
|
+
const { privateKey } = generateKeyPairSync('ed25519');
|
|
1466
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1467
|
+
writeFileSync(path, privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600 });
|
|
1468
|
+
return privateKey;
|
|
1469
|
+
}
|
|
1470
|
+
/**
|
|
1471
|
+
* Derive the default wire session label from a per-home node id: eight hex
|
|
1472
|
+
* characters generated once, so two deployments mounting defaults do not
|
|
1473
|
+
* collide while a persisted home keeps the label stable across restarts.
|
|
1474
|
+
* @param home - resolved harness home.
|
|
1475
|
+
* @returns the derived session label (`dsh-host-<8 hex>`).
|
|
1476
|
+
*/
|
|
1477
|
+
function deriveSession(home) {
|
|
1478
|
+
const path = join(home, 'a2a', 'node-id');
|
|
1479
|
+
if (existsSync(path))
|
|
1480
|
+
return `dsh-host-${readFileSync(path, 'utf8').trim()}`;
|
|
1481
|
+
const id = randomBytes(4).toString('hex');
|
|
1482
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1483
|
+
writeFileSync(path, `${id}\n`, { mode: 0o600 });
|
|
1484
|
+
return `dsh-host-${id}`;
|
|
1485
|
+
}
|