@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/invariant.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Package-owned invariant companion. @module @nelsonlongxiang/dsh-open-a2a-net/invariant */
|
|
2
|
+
const PACKAGE_NAME = '@nelsonlongxiang/dsh-open-a2a-net';
|
|
3
|
+
/** Cordis companion plugin name. */
|
|
4
|
+
export const name = 'a2a-invariant';
|
|
5
|
+
/** Service required before the companion can reserve package ownership. */
|
|
6
|
+
export const inject = ['invariants'];
|
|
7
|
+
/**
|
|
8
|
+
* No runtime invariant: the plugin owns no session-event types. Inbound relay
|
|
9
|
+
* messages land as ordinary `user/message` events (source `plugin/a2a`,
|
|
10
|
+
* form `relay`) whose shape the core already validates, and tool results ride
|
|
11
|
+
* the generic tool pipeline.
|
|
12
|
+
*/
|
|
13
|
+
const install = () => { };
|
|
14
|
+
/** Register this package's invariant companion. */
|
|
15
|
+
export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
16
|
+
/* jscpd:ignore-end */
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted session-node join intent (`<dsh-home>/a2a/joined.json`). A join
|
|
3
|
+
* is a user gesture over a durable session, but the node it mounts lives and
|
|
4
|
+
* dies with the session's Agent instance — page reloads and host restarts
|
|
5
|
+
* dispose Agents, which silently dropped every join. The store keeps the
|
|
6
|
+
* intent across those lifecycles: the plugin remounts the node whenever the
|
|
7
|
+
* session's Agent comes back, until the user leaves (the only intent
|
|
8
|
+
* remover; disposal alone never unpersists).
|
|
9
|
+
* @module @nelsonlongxiang/dsh-open-a2a-net/joined-store
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { dirname } from 'node:path';
|
|
13
|
+
/**
|
|
14
|
+
* Hard cap on remembered join intents. Ids of deleted sessions linger
|
|
15
|
+
* harmlessly (no Agent ever comes back for them); the cap bounds the file,
|
|
16
|
+
* keeping the most recent intents (peer-store's bound rationale).
|
|
17
|
+
*/
|
|
18
|
+
export const JOIN_CAP = 64;
|
|
19
|
+
/**
|
|
20
|
+
* Bounded set of session ids whose user joined them to the A2A network,
|
|
21
|
+
* with whole-file persistence. Pure of any registry knowledge: callers ask
|
|
22
|
+
* {@link has} when an Agent appears and record {@link add} / {@link remove}
|
|
23
|
+
* on the join and leave operations.
|
|
24
|
+
*/
|
|
25
|
+
export class JoinedSessions {
|
|
26
|
+
path;
|
|
27
|
+
/** Session ids, most recently joined first. */
|
|
28
|
+
ids = [];
|
|
29
|
+
/**
|
|
30
|
+
* @param path - persistence file (`<dsh-home>/a2a/joined.json`); empty = no persistence.
|
|
31
|
+
*/
|
|
32
|
+
constructor(path) {
|
|
33
|
+
this.path = path;
|
|
34
|
+
this.restore();
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Whether the session's join intent is remembered.
|
|
38
|
+
* @param id - the session id to check.
|
|
39
|
+
* @returns whether the intent is present.
|
|
40
|
+
*/
|
|
41
|
+
has(id) {
|
|
42
|
+
return this.ids.includes(id);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Every remembered join intent, most recently joined first.
|
|
46
|
+
* @returns the remembered session ids.
|
|
47
|
+
*/
|
|
48
|
+
list() {
|
|
49
|
+
return [...this.ids];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Remember one join intent, most-recent first, bounded by {@link JOIN_CAP}
|
|
53
|
+
* (the oldest intent beyond the cap is forgotten).
|
|
54
|
+
* @param id - the joined session id.
|
|
55
|
+
*/
|
|
56
|
+
add(id) {
|
|
57
|
+
this.ids = [id, ...this.ids.filter(entry => entry !== id)].slice(0, JOIN_CAP);
|
|
58
|
+
this.persist();
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Forget one join intent (the leave operation; a no-op when absent).
|
|
62
|
+
* @param id - the session id whose intent is removed.
|
|
63
|
+
*/
|
|
64
|
+
remove(id) {
|
|
65
|
+
if (!this.has(id))
|
|
66
|
+
return;
|
|
67
|
+
this.ids = this.ids.filter(entry => entry !== id);
|
|
68
|
+
this.persist();
|
|
69
|
+
}
|
|
70
|
+
/** Load a persisted snapshot on construction, if present. */
|
|
71
|
+
restore() {
|
|
72
|
+
if (this.path === '' || !existsSync(this.path))
|
|
73
|
+
return;
|
|
74
|
+
try {
|
|
75
|
+
const snapshot = JSON.parse(readFileSync(this.path, 'utf8'));
|
|
76
|
+
const sessions = snapshot?.sessions;
|
|
77
|
+
if (!Array.isArray(sessions))
|
|
78
|
+
return;
|
|
79
|
+
this.ids = sessions.filter((entry) => typeof entry === 'string' && entry !== '').slice(0, JOIN_CAP);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// A corrupt store is not fatal: the user re-joins; nothing else is lost.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Persist the intent set (no-op when no path was configured). */
|
|
86
|
+
persist() {
|
|
87
|
+
if (this.path === '')
|
|
88
|
+
return;
|
|
89
|
+
try {
|
|
90
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
91
|
+
const snapshot = { sessions: [...this.ids] };
|
|
92
|
+
writeFileSync(this.path, JSON.stringify(snapshot), { mode: 0o600 });
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// An unwritable home must not break joining; the intent degrades to memory-only.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded, quality-scored peer store (GNUnet hostlist semantics, slice 2).
|
|
3
|
+
* The configured peer list is a seed set, not the network: referral URLs
|
|
4
|
+
* learned from fetched cards join the store, are bounded and quality-scored,
|
|
5
|
+
* and persist across restarts. Failed fetches degrade a peer; a bad peer that
|
|
6
|
+
* keeps failing is evicted, while seeds stay for the lifetime of the config
|
|
7
|
+
* that named them. Design basis:
|
|
8
|
+
* .agents/notes/proposed/feature/2026-08-16-gnunet-prior-art-for-a2a-topology.md.
|
|
9
|
+
* @module @nelsonlongxiang/dsh-open-a2a-net/peer-store
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { dirname } from 'node:path';
|
|
13
|
+
/** Hard cap on tracked peers, seeds included (hostlist bound: 30). */
|
|
14
|
+
export const PEER_CAP = 30;
|
|
15
|
+
/** Initial quality score of a seed or newly offered peer. */
|
|
16
|
+
export const INITIAL_SCORE = 10_000;
|
|
17
|
+
/** Quality penalty on a failed fetch. */
|
|
18
|
+
export const FAILURE_PENALTY = 100;
|
|
19
|
+
/** Quality reward on a successful fetch. */
|
|
20
|
+
export const SUCCESS_REWARD = 100;
|
|
21
|
+
/** Score floor: at or below this a non-seed peer is evicted. */
|
|
22
|
+
export const EVICTION_FLOOR = 0;
|
|
23
|
+
/**
|
|
24
|
+
* Bounded, quality-scored peer collection with whole-file persistence.
|
|
25
|
+
* Pure of any network: the caller drives fetch outcomes through
|
|
26
|
+
* {@link noteFailure} / {@link noteSuccess} and feeds referral URLs through
|
|
27
|
+
* {@link offer}.
|
|
28
|
+
*/
|
|
29
|
+
export class PeerStore {
|
|
30
|
+
path;
|
|
31
|
+
cap;
|
|
32
|
+
peers = new Map();
|
|
33
|
+
/**
|
|
34
|
+
* @param seeds - the configured seed URLs; always retained (never evicted).
|
|
35
|
+
* @param path - persistence file (`<dsh-home>/a2a/peers.json`); empty = no persistence.
|
|
36
|
+
* @param cap - peer bound, seeds included (default {@link PEER_CAP}).
|
|
37
|
+
*/
|
|
38
|
+
constructor(seeds, path, cap = PEER_CAP) {
|
|
39
|
+
this.path = path;
|
|
40
|
+
this.cap = cap;
|
|
41
|
+
this.restore();
|
|
42
|
+
for (const seed of seeds) {
|
|
43
|
+
if (seed === '')
|
|
44
|
+
continue;
|
|
45
|
+
// The config is authoritative for a seed: it is (re)marked as one —
|
|
46
|
+
// immune to eviction — and restarts from the initial score regardless
|
|
47
|
+
// of the persisted one. Replacing the record wholesale keeps PeerRecord
|
|
48
|
+
// fields readonly.
|
|
49
|
+
this.peers.set(seed, { url: seed, score: INITIAL_SCORE, seed: true });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Every tracked peer URL: seeds first (in first-tracked order), then
|
|
54
|
+
* offered peers by score descending (equal scores keep first-tracked order).
|
|
55
|
+
* @returns the ordered peer URLs.
|
|
56
|
+
*/
|
|
57
|
+
list() {
|
|
58
|
+
return [...this.peers.values()]
|
|
59
|
+
.sort((a, b) => (a.seed === b.seed ? b.score - a.score : a.seed ? -1 : 1))
|
|
60
|
+
.map(peer => peer.url);
|
|
61
|
+
}
|
|
62
|
+
/** Whether the store has room for one more peer. */
|
|
63
|
+
hasRoom() {
|
|
64
|
+
return this.peers.size < this.cap;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Offer a referral URL learned from a fetched card. Bounded: an unknown
|
|
68
|
+
* URL only joins when under the cap; a known URL changes nothing.
|
|
69
|
+
* @param url - the referred card URL.
|
|
70
|
+
* @returns whether the peer is tracked (freshly joined or already known).
|
|
71
|
+
*/
|
|
72
|
+
offer(url) {
|
|
73
|
+
if (url === '')
|
|
74
|
+
return false;
|
|
75
|
+
if (this.peers.has(url))
|
|
76
|
+
return true;
|
|
77
|
+
if (!this.hasRoom())
|
|
78
|
+
return false;
|
|
79
|
+
this.peers.set(url, { url, score: INITIAL_SCORE, seed: false });
|
|
80
|
+
this.persist();
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Drop one tracked peer outright — a self-referral (a peer listing this
|
|
85
|
+
* node's own URL back at it) must not linger as a tracked peer. Seeds are
|
|
86
|
+
* config-owned and stay.
|
|
87
|
+
* @param url - the peer to forget.
|
|
88
|
+
*/
|
|
89
|
+
drop(url) {
|
|
90
|
+
const peer = this.peers.get(url);
|
|
91
|
+
if (peer === undefined || peer.seed)
|
|
92
|
+
return;
|
|
93
|
+
this.peers.delete(url);
|
|
94
|
+
this.persist();
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Note a failed fetch of one peer. A non-seed's score drops; an
|
|
98
|
+
* at-or-below-floor peer is evicted, so a bad referral disappears rather
|
|
99
|
+
* than degrading forever. Seeds are config-owned and never penalized.
|
|
100
|
+
* @param url - the peer that failed.
|
|
101
|
+
*/
|
|
102
|
+
noteFailure(url) {
|
|
103
|
+
const peer = this.peers.get(url);
|
|
104
|
+
if (peer === undefined || peer.seed)
|
|
105
|
+
return;
|
|
106
|
+
peer.score -= FAILURE_PENALTY;
|
|
107
|
+
if (peer.score <= EVICTION_FLOOR)
|
|
108
|
+
this.peers.delete(url);
|
|
109
|
+
this.persist();
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Note a successful fetch of one peer.
|
|
113
|
+
* @param url - the peer that answered with a verified card.
|
|
114
|
+
*/
|
|
115
|
+
noteSuccess(url) {
|
|
116
|
+
const peer = this.peers.get(url);
|
|
117
|
+
if (peer === undefined)
|
|
118
|
+
return;
|
|
119
|
+
peer.score += SUCCESS_REWARD;
|
|
120
|
+
this.persist();
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The current quality score of one tracked peer, for state reporting.
|
|
124
|
+
* @param url - the peer URL.
|
|
125
|
+
* @returns the score, or undefined when the peer is not tracked.
|
|
126
|
+
*/
|
|
127
|
+
score(url) {
|
|
128
|
+
return this.peers.get(url)?.score;
|
|
129
|
+
}
|
|
130
|
+
/** Load a persisted snapshot on construction, if present. */
|
|
131
|
+
restore() {
|
|
132
|
+
if (this.path === '' || !existsSync(this.path))
|
|
133
|
+
return;
|
|
134
|
+
try {
|
|
135
|
+
const snapshot = JSON.parse(readFileSync(this.path, 'utf8'));
|
|
136
|
+
const peers = snapshot?.peers;
|
|
137
|
+
if (!Array.isArray(peers))
|
|
138
|
+
return;
|
|
139
|
+
for (const entry of peers) {
|
|
140
|
+
const peer = entry;
|
|
141
|
+
if (typeof peer?.url !== 'string' || peer.url === '')
|
|
142
|
+
continue;
|
|
143
|
+
this.peers.set(peer.url, {
|
|
144
|
+
url: peer.url,
|
|
145
|
+
score: typeof peer.score === 'number' && Number.isFinite(peer.score) ? peer.score : INITIAL_SCORE,
|
|
146
|
+
seed: peer.seed === true,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// A corrupt store is not fatal: fall back to seeds only.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** Persist the current peer set (no-op when no path was configured). */
|
|
155
|
+
persist() {
|
|
156
|
+
if (this.path === '')
|
|
157
|
+
return;
|
|
158
|
+
try {
|
|
159
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
160
|
+
const snapshot = {
|
|
161
|
+
peers: [...this.peers.values()].map(peer => ({ ...peer })),
|
|
162
|
+
};
|
|
163
|
+
writeFileSync(this.path, JSON.stringify(snapshot), { mode: 0o600 });
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// An unwritable home must not break routing; the store degrades to memory-only.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process decentralized A2A client: the outbound peer surface — verified
|
|
3
|
+
* agent-card fetches (discovery) and direct routes to a peer's
|
|
4
|
+
* `/a2a/direct` endpoint. Timing and fetch seams are constructor-injected so
|
|
5
|
+
* tests drive them without a network.
|
|
6
|
+
* @module @nelsonlongxiang/dsh-open-a2a-net/a2a-client
|
|
7
|
+
*/
|
|
8
|
+
import type { A2aPeerCard, A2aRouteResult } from './types.ts';
|
|
9
|
+
/** One-shot delayed callback returning its disposer. */
|
|
10
|
+
export type A2aSchedule = (callback: () => void, delayMs: number) => () => void;
|
|
11
|
+
/** Minimal fetch face; the first HTTP-level failure rejects. */
|
|
12
|
+
export type A2aFetch = (url: string, init: {
|
|
13
|
+
readonly method: 'GET' | 'POST';
|
|
14
|
+
readonly headers: Record<string, string>;
|
|
15
|
+
readonly body?: string;
|
|
16
|
+
readonly signal?: AbortSignal;
|
|
17
|
+
}) => Promise<{
|
|
18
|
+
readonly ok: boolean;
|
|
19
|
+
readonly status: number;
|
|
20
|
+
readonly text: () => Promise<string>;
|
|
21
|
+
}>;
|
|
22
|
+
/** Constructor facts; every seam is explicit so tests inject doubles. */
|
|
23
|
+
export interface A2aClientOptions {
|
|
24
|
+
/** `X-API-Key` value sent on peer requests; empty omits the header. */
|
|
25
|
+
readonly apiKey: string;
|
|
26
|
+
/** This node's caller label stamped on outbound routes. */
|
|
27
|
+
readonly sessionId: string;
|
|
28
|
+
/** Delayed-callback seam (HTTP timeouts). */
|
|
29
|
+
readonly schedule: A2aSchedule;
|
|
30
|
+
/** HTTP seam. */
|
|
31
|
+
readonly fetch: A2aFetch;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The A2A peer client. One instance serves the whole fiber's outbound
|
|
35
|
+
* card fetches and direct routes; it owns no connection state.
|
|
36
|
+
*/
|
|
37
|
+
export declare class A2aClient {
|
|
38
|
+
private readonly options;
|
|
39
|
+
/** @param options - resolved caller facts and injected seams. */
|
|
40
|
+
constructor(options: A2aClientOptions);
|
|
41
|
+
private headers;
|
|
42
|
+
private http;
|
|
43
|
+
/**
|
|
44
|
+
* Fetch one peer's agent card (discovery): team, session, capabilities.
|
|
45
|
+
* The card is verified at parse — unsigned, badly signed, or expired cards
|
|
46
|
+
* are rejected here, so a stale peer disappears from discovery without any
|
|
47
|
+
* background cleanup.
|
|
48
|
+
* @param baseUrl - the peer's base URL.
|
|
49
|
+
* @returns the verified card, or `undefined` when unreachable, malformed, or rejected.
|
|
50
|
+
*/
|
|
51
|
+
fetchCard(baseUrl: string): Promise<A2aPeerCard | undefined>;
|
|
52
|
+
/**
|
|
53
|
+
* Extract the reply text from a route result member: the wire carries a
|
|
54
|
+
* plain string, a `{text}` object, or anything else JSON-encoded.
|
|
55
|
+
* @param inner - the raw `result` member of a route response.
|
|
56
|
+
* @param fallback - what a nullish member encodes as.
|
|
57
|
+
* @returns the reply text.
|
|
58
|
+
*/
|
|
59
|
+
private replyText;
|
|
60
|
+
/**
|
|
61
|
+
* Route directly to a peer node's `/a2a/direct` endpoint. The peer steers
|
|
62
|
+
* the message into its live agent (or the named session team's agent) and
|
|
63
|
+
* answers within its own final-reply budget.
|
|
64
|
+
* @param baseUrl - the peer's base URL.
|
|
65
|
+
* @param team - target team name (the peer's own or a session team).
|
|
66
|
+
* @param message - request text.
|
|
67
|
+
* @param contextId - context id from a prior reply, for multi-turn.
|
|
68
|
+
* @param signal - caller cancellation.
|
|
69
|
+
* @param callerSession - caller label overriding the node's label (the calling session's node label).
|
|
70
|
+
* @returns the canonical route result (success or explicit failure).
|
|
71
|
+
*/
|
|
72
|
+
routeDirect(baseUrl: string, team: string, message: string, contextId?: string, signal?: AbortSignal, callerSession?: string, asyncMode?: boolean, taskIdFromCaller?: string): Promise<A2aRouteResult>;
|
|
73
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expiring signed agent cards (GNUnet HELLO semantics): the card carries its
|
|
3
|
+
* own expiry and an Ed25519 signature over identity, team, capabilities, and
|
|
4
|
+
* expiry, so only the key holder can extend a card's life. Expiry and the
|
|
5
|
+
* signature are enforced wherever a card is parsed — no background cleanup.
|
|
6
|
+
* Design basis: .agents/notes/proposed/feature/2026-08-16-gnunet-prior-art-for-a2a-topology.md (slice 1).
|
|
7
|
+
* @module @nelsonlongxiang/dsh-open-a2a-net/card
|
|
8
|
+
*/
|
|
9
|
+
import { type KeyObject } from 'node:crypto';
|
|
10
|
+
import type { A2aPeerCard } from './types.ts';
|
|
11
|
+
/** Card validity budget. GNUnet HELLO default (2 days). */
|
|
12
|
+
export declare const CARD_TTL_MS = 172800000;
|
|
13
|
+
/** Announce re-sign cadence: four publication opportunities per TTL. */
|
|
14
|
+
export declare const CARD_REFRESH_MS: number;
|
|
15
|
+
/**
|
|
16
|
+
* The card fields the signature commits to; every field is lifecycle-relevant.
|
|
17
|
+
* `peers` is deliberately not a core field: a referral is verified as its own
|
|
18
|
+
* card at fetch time (hostlist semantics), so a tampered referral list only
|
|
19
|
+
* produces fetch failures, which the caller's peer store penalizes. `records`
|
|
20
|
+
* is a core field: a delegation is an authority claim over a name, and only
|
|
21
|
+
* the zone key holder may publish one. Cards without records sign exactly the
|
|
22
|
+
* field set above, so their signatures stay byte-identical.
|
|
23
|
+
*/
|
|
24
|
+
export type CardCore = Pick<A2aPeerCard, 'name' | 'session' | 'team' | 'capabilities' | 'expiresAt' | 'records'>;
|
|
25
|
+
/** Why a parsed card was rejected at verification. */
|
|
26
|
+
export type CardRejection = 'malformed' | 'unsigned' | 'expired' | 'bad-signature';
|
|
27
|
+
/** The verification outcome: the card, or the rejection reason. */
|
|
28
|
+
export type CardVerification = {
|
|
29
|
+
readonly ok: true;
|
|
30
|
+
readonly card: A2aPeerCard;
|
|
31
|
+
} | {
|
|
32
|
+
readonly ok: false;
|
|
33
|
+
readonly reason: CardRejection;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Encode a public key as the base64 SPKI DER the card carries.
|
|
37
|
+
* @param publicKey - the signing node's Ed25519 public key.
|
|
38
|
+
* @returns the base64 SPKI DER string.
|
|
39
|
+
*/
|
|
40
|
+
export declare function encodePublicKey(publicKey: KeyObject): string;
|
|
41
|
+
/**
|
|
42
|
+
* Sign one card core with the node's Ed25519 private key.
|
|
43
|
+
* @param core - the card's lifecycle-relevant fields, expiry included.
|
|
44
|
+
* @param privateKey - the signing node's Ed25519 private key.
|
|
45
|
+
* @returns the wire card: the core plus the public key and signature.
|
|
46
|
+
*/
|
|
47
|
+
export declare function signCard(core: CardCore, privateKey: KeyObject): A2aPeerCard;
|
|
48
|
+
/**
|
|
49
|
+
* Verify a parsed card: shape, signature, and expiry, in that order.
|
|
50
|
+
* @param candidate - the JSON value fetched from a peer's agent-card URL.
|
|
51
|
+
* @param now - evaluation time (epoch ms); injected for tests.
|
|
52
|
+
* @returns the verified card, or the first rejection reason.
|
|
53
|
+
*/
|
|
54
|
+
export declare function verifyCard(candidate: unknown, now: number): CardVerification;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
|
|
2
|
+
/** One session as the host's state route reports it. */
|
|
3
|
+
export interface A2aSessionRow {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly label: string;
|
|
6
|
+
readonly team: string;
|
|
7
|
+
readonly name?: string;
|
|
8
|
+
readonly description?: string;
|
|
9
|
+
readonly workspace?: string;
|
|
10
|
+
readonly joined: boolean;
|
|
11
|
+
/** `false` on cold rows: the join intent is persisted but the session's agent is not loaded. */
|
|
12
|
+
readonly live?: boolean;
|
|
13
|
+
/** The user-named group this session is assigned to, if any. */
|
|
14
|
+
readonly group?: string;
|
|
15
|
+
}
|
|
16
|
+
/** One peer-side team row as the state route reports it (origin is its natural group). */
|
|
17
|
+
export interface A2aRemoteRow {
|
|
18
|
+
readonly team: string;
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly origin?: string;
|
|
21
|
+
readonly workspace?: string;
|
|
22
|
+
}
|
|
23
|
+
/** One tracked peer as the state route reports it. */
|
|
24
|
+
export interface A2aPeerRow {
|
|
25
|
+
readonly url: string;
|
|
26
|
+
readonly score?: number;
|
|
27
|
+
}
|
|
28
|
+
/** One routing outcome as the state route reports it. */
|
|
29
|
+
export interface A2aActivityRow {
|
|
30
|
+
readonly ts: number;
|
|
31
|
+
readonly dir: 'in' | 'out';
|
|
32
|
+
readonly team: string;
|
|
33
|
+
readonly peer: string;
|
|
34
|
+
readonly ok: boolean;
|
|
35
|
+
}
|
|
36
|
+
/** One in-flight outbound route as the state route reports it. */
|
|
37
|
+
export interface A2aInFlightRow {
|
|
38
|
+
readonly team: string;
|
|
39
|
+
readonly peer: string;
|
|
40
|
+
readonly startedAt: number;
|
|
41
|
+
}
|
|
42
|
+
/** The state route's full body. */
|
|
43
|
+
export interface A2aState {
|
|
44
|
+
readonly sessions: readonly A2aSessionRow[];
|
|
45
|
+
readonly groups: readonly string[];
|
|
46
|
+
readonly remote: readonly A2aRemoteRow[];
|
|
47
|
+
readonly peers: readonly A2aPeerRow[];
|
|
48
|
+
readonly activity: readonly A2aActivityRow[];
|
|
49
|
+
readonly inFlight: readonly A2aInFlightRow[];
|
|
50
|
+
/** The host plugin package version, when the state route reports it. */
|
|
51
|
+
readonly version?: string;
|
|
52
|
+
}
|
|
53
|
+
/** Wake face the registration injects: opens one session through the standard sessions flow. */
|
|
54
|
+
export interface A2aControlInjected {
|
|
55
|
+
readonly openSession: (id: string) => void;
|
|
56
|
+
}
|
|
57
|
+
/** Full component props: the footer-action owner share, the locale seat, and the wake face. */
|
|
58
|
+
export type A2aControlProps = PropsRuntime<'sidebar.footer.action'> & PropsLocale<'a2aNet'> & A2aControlInjected;
|
|
59
|
+
/**
|
|
60
|
+
* Render the network toggle and its network panel.
|
|
61
|
+
* @param props - composed slot props.
|
|
62
|
+
* @returns the control element tree.
|
|
63
|
+
*/
|
|
64
|
+
export declare function A2aControl({ wide, t, useSessions, openSession }: A2aControlProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open A2A network plugin, browser half: a sidebar footer action listing
|
|
3
|
+
* this host's sessions as joinable network nodes. The entry probes the
|
|
4
|
+
* host's session-node state route at registration time — the seat renders
|
|
5
|
+
* only on a host that serves it (the a2a host half composed with session
|
|
6
|
+
* nodes, which is its default) — and each row joins/leaves through the
|
|
7
|
+
* guarded control routes. Opening a cold row (persisted join intent, agent
|
|
8
|
+
* not back after a restart) goes through the standard sessions flow; the
|
|
9
|
+
* host remounts the node when the session loads.
|
|
10
|
+
*/
|
|
11
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
|
|
12
|
+
import { type A2aNetKey } from './locales.ts';
|
|
13
|
+
export type { A2aControlInjected, A2aControlProps, A2aSessionRow } from './A2aControl.tsx';
|
|
14
|
+
export type { A2aNetKey } from './locales.ts';
|
|
15
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
16
|
+
interface LocaleNamespaceMap {
|
|
17
|
+
/** A2A network sidebar control copy. */
|
|
18
|
+
a2aNet: A2aNetKey;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Services required by the browser half: the slot system, the sessions
|
|
22
|
+
* flow used to wake cold rows, and the locale dictionaries. */
|
|
23
|
+
export declare const inject: string[];
|
|
24
|
+
/** Registers the network control's dictionaries and its sidebar seat.
|
|
25
|
+
* @param ctx - Client root context.
|
|
26
|
+
*/
|
|
27
|
+
export declare function apply(ctx: ClientContext): void;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Locale dictionaries for the A2A network sidebar control (namespace `a2aNet`). */
|
|
2
|
+
/** Dictionary keys owned by the `a2aNet` namespace. */
|
|
3
|
+
export type A2aNetKey = 'a2a.label' | 'a2a.title' | 'a2a.empty' | 'a2a.join' | 'a2a.leave' | 'a2a.cold' | 'a2a.wake' | 'a2a.note' | 'a2a.remote' | 'a2a.remoteUnknownOrigin' | 'a2a.peers' | 'a2a.peersEmpty' | 'a2a.activity' | 'a2a.activityEmpty' | 'a2a.inFlight' | 'a2a.inFlightStale' | 'a2a.jump' | 'a2a.search' | 'a2a.searchEmpty' | 'a2a.group' | 'a2a.groupDefault' | 'a2a.groupClear' | 'a2a.groupNew' | 'a2a.groupNewGo';
|
|
4
|
+
/** Simplified Chinese dictionary. */
|
|
5
|
+
export declare const zh: Record<A2aNetKey, string>;
|
|
6
|
+
/** English dictionary. */
|
|
7
|
+
export declare const en: Record<A2aNetKey, string>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process decentralized A2A client: the outbound peer surface — verified
|
|
3
|
+
* agent-card fetches (discovery) and direct routes to a peer's
|
|
4
|
+
* `/a2a/direct` endpoint. Timing and fetch seams are constructor-injected so
|
|
5
|
+
* tests drive them without a network.
|
|
6
|
+
* @module @nelsonlongxiang/dsh-open-a2a-net/client
|
|
7
|
+
*/
|
|
8
|
+
import type { A2aPeerCard, A2aRouteResult } from './types.ts';
|
|
9
|
+
/** One-shot delayed callback returning its disposer. */
|
|
10
|
+
export type A2aSchedule = (callback: () => void, delayMs: number) => () => void;
|
|
11
|
+
/** Minimal fetch face; the first HTTP-level failure rejects. */
|
|
12
|
+
export type A2aFetch = (url: string, init: {
|
|
13
|
+
readonly method: 'GET' | 'POST';
|
|
14
|
+
readonly headers: Record<string, string>;
|
|
15
|
+
readonly body?: string;
|
|
16
|
+
readonly signal?: AbortSignal;
|
|
17
|
+
}) => Promise<{
|
|
18
|
+
readonly ok: boolean;
|
|
19
|
+
readonly status: number;
|
|
20
|
+
readonly text: () => Promise<string>;
|
|
21
|
+
}>;
|
|
22
|
+
/** Constructor facts; every seam is explicit so tests inject doubles. */
|
|
23
|
+
export interface A2aClientOptions {
|
|
24
|
+
/** `X-API-Key` value sent on peer requests; empty omits the header. */
|
|
25
|
+
readonly apiKey: string;
|
|
26
|
+
/** This node's caller label stamped on outbound routes. */
|
|
27
|
+
readonly sessionId: string;
|
|
28
|
+
/** Delayed-callback seam (HTTP timeouts). */
|
|
29
|
+
readonly schedule: A2aSchedule;
|
|
30
|
+
/** HTTP seam. */
|
|
31
|
+
readonly fetch: A2aFetch;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The A2A peer client. One instance serves the whole fiber's outbound
|
|
35
|
+
* card fetches and direct routes; it owns no connection state.
|
|
36
|
+
*/
|
|
37
|
+
export declare class A2aClient {
|
|
38
|
+
private readonly options;
|
|
39
|
+
/** @param options - resolved caller facts and injected seams. */
|
|
40
|
+
constructor(options: A2aClientOptions);
|
|
41
|
+
private headers;
|
|
42
|
+
private http;
|
|
43
|
+
/**
|
|
44
|
+
* Fetch one peer's agent card (discovery): team, session, capabilities.
|
|
45
|
+
* The card is verified at parse — unsigned, badly signed, or expired cards
|
|
46
|
+
* are rejected here, so a stale peer disappears from discovery without any
|
|
47
|
+
* background cleanup.
|
|
48
|
+
* @param baseUrl - the peer's base URL.
|
|
49
|
+
* @returns the verified card, or `undefined` when unreachable, malformed, or rejected.
|
|
50
|
+
*/
|
|
51
|
+
fetchCard(baseUrl: string): Promise<A2aPeerCard | undefined>;
|
|
52
|
+
/**
|
|
53
|
+
* Extract the reply text from a route result member: the wire carries a
|
|
54
|
+
* plain string, a `{text}` object, or anything else JSON-encoded.
|
|
55
|
+
* @param inner - the raw `result` member of a route response.
|
|
56
|
+
* @param fallback - what a nullish member encodes as.
|
|
57
|
+
* @returns the reply text.
|
|
58
|
+
*/
|
|
59
|
+
private replyText;
|
|
60
|
+
/**
|
|
61
|
+
* Route directly to a peer node's `/a2a/direct` endpoint. The peer steers
|
|
62
|
+
* the message into its live agent (or the named session team's agent) and
|
|
63
|
+
* answers within its own final-reply budget.
|
|
64
|
+
* @param baseUrl - the peer's base URL.
|
|
65
|
+
* @param team - target team name (the peer's own or a session team).
|
|
66
|
+
* @param message - request text.
|
|
67
|
+
* @param contextId - context id from a prior reply, for multi-turn.
|
|
68
|
+
* @param signal - caller cancellation.
|
|
69
|
+
* @param callerSession - caller label overriding the node's label (the calling session's node label).
|
|
70
|
+
* @returns the canonical route result (success or explicit failure).
|
|
71
|
+
*/
|
|
72
|
+
routeDirect(baseUrl: string, team: string, message: string, contextId?: string, signal?: AbortSignal, callerSession?: string): Promise<A2aRouteResult>;
|
|
73
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted session groups: user-named buckets for the network panel's
|
|
3
|
+
* session listing, stored under the a2a home so they survive restarts and
|
|
4
|
+
* follow the node (not the browser). Plain JSON, one writer (the control
|
|
5
|
+
* routes), read fresh by the state route.
|
|
6
|
+
* @module @nelsonlongxiang/dsh-open-a2a-net/group-store
|
|
7
|
+
*/
|
|
8
|
+
/** On-disk shape; assignments map a session id to its group name. */
|
|
9
|
+
export interface GroupSnapshot {
|
|
10
|
+
readonly groups: readonly string[];
|
|
11
|
+
readonly assignments: Readonly<Record<string, string>>;
|
|
12
|
+
}
|
|
13
|
+
/** Upper bound on named groups (a panel taxonomy, not a database). */
|
|
14
|
+
export declare const GROUP_CAP = 20;
|
|
15
|
+
/**
|
|
16
|
+
* Bounded group store with last-write-wins persistence.
|
|
17
|
+
*/
|
|
18
|
+
export declare class GroupStore {
|
|
19
|
+
private groups;
|
|
20
|
+
private assignments;
|
|
21
|
+
private readonly path;
|
|
22
|
+
/**
|
|
23
|
+
* @param path - the snapshot file; an empty path keeps the store in memory.
|
|
24
|
+
*/
|
|
25
|
+
constructor(path: string);
|
|
26
|
+
/** Load a persisted snapshot, if present and well-formed. */
|
|
27
|
+
private restore;
|
|
28
|
+
/** Persist the current snapshot (no-op without a path). */
|
|
29
|
+
private persist;
|
|
30
|
+
/** Every named group, in creation order. */
|
|
31
|
+
list(): readonly string[];
|
|
32
|
+
/**
|
|
33
|
+
* The group a session belongs to, if any.
|
|
34
|
+
* @param id - the session id.
|
|
35
|
+
*/
|
|
36
|
+
groupOf(id: string): string | undefined;
|
|
37
|
+
/** Every assignment, for the state route's session rows. */
|
|
38
|
+
all(): Readonly<Record<string, string>>;
|
|
39
|
+
/**
|
|
40
|
+
* Create a named group; whitespace trims, duplicates are idempotent, and
|
|
41
|
+
* the cap rejects a group beyond {@link GROUP_CAP}.
|
|
42
|
+
* @param name - the raw user-supplied name.
|
|
43
|
+
* @returns the stored name, or undefined when rejected.
|
|
44
|
+
*/
|
|
45
|
+
create(name: string): string | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* Assign one session to an existing group (creating it when new and
|
|
48
|
+
* under the cap); an empty group name unassigns.
|
|
49
|
+
* @param id - the session id.
|
|
50
|
+
* @param name - the group name, or '' to clear.
|
|
51
|
+
*/
|
|
52
|
+
assign(id: string, name: string): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Delete an empty-or-not group; its assignments clear with it.
|
|
55
|
+
* @param name - the group name.
|
|
56
|
+
*/
|
|
57
|
+
remove(name: string): boolean;
|
|
58
|
+
}
|