dsh-a2a-groups 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-ui.md +350 -0
- package/README.md +160 -2
- package/bin/agent-groups.js +438 -0
- package/client.js +3782 -0
- package/cordis.patch.yml +17 -0
- package/docs/ARCHITECTURE.md +281 -0
- package/docs/BACKLOG.md +201 -0
- package/docs/CONTRACT.md +592 -0
- package/docs/DEPLOY.md +645 -0
- package/docs/DESIGN.md +390 -0
- package/docs/MCP.md +260 -0
- package/docs/PRODUCT.md +186 -0
- package/docs/RUNBOOK.md +406 -0
- package/lib/core/bridge.js +1167 -0
- package/lib/core/card.js +217 -0
- package/lib/core/colleague-cards.js +149 -0
- package/lib/core/config.js +235 -0
- package/lib/core/endpoint.js +69 -0
- package/lib/core/groups.js +3010 -0
- package/lib/core/invite.js +412 -0
- package/lib/core/memberships.js +366 -0
- package/lib/core/probe.js +219 -0
- package/lib/core/registry.js +344 -0
- package/lib/core/server.js +1304 -0
- package/lib/core/skills-group.js +1164 -0
- package/lib/core/skills-session.js +116 -0
- package/lib/core/skills.js +76 -0
- package/lib/core/wire.js +772 -0
- package/lib/dsh/deliver.js +601 -0
- package/lib/dsh/inbox.js +658 -0
- package/lib/dsh/profile.js +195 -0
- package/lib/dsh/prompt.js +55 -0
- package/lib/dsh/sessions.js +278 -0
- package/lib/dsh/toolkit.js +166 -0
- package/lib/dsh/tools.js +101 -0
- package/lib/index.js +648 -0
- package/lib/mcp/mcp-deps.js +145 -0
- package/lib/mcp-server.js +261 -0
- package/lib/shared/tool-runtime.js +1349 -0
- package/lib/shared/tool-specs.js +1123 -0
- package/lib/types/client.d.ts +144 -0
- package/package.json +84 -4
package/lib/core/card.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The A2A agent card: what a peer reads before it sends anything.
|
|
3
|
+
*
|
|
4
|
+
* The card is a promise. Every field in it is something the server will honour
|
|
5
|
+
* or something a peer will branch on, so it is assembled from the same config
|
|
6
|
+
* and the same skill registry the dispatcher uses — never hand-written. A card
|
|
7
|
+
* that advertises a skill the dispatcher does not route is worse than no card at
|
|
8
|
+
* all: the peer discovers it, calls it, and gets `-32601`.
|
|
9
|
+
*
|
|
10
|
+
* Boundaries:
|
|
11
|
+
* - No HTTP. `buildCard()` returns a value; the server decides where to serve it.
|
|
12
|
+
* - Path constants live here because both the server routes and the card's
|
|
13
|
+
* `supportedInterfaces[].url` must agree, and a divergence is silent.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-a2a/card
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { PROTOCOL_VERSION } from './wire.js';
|
|
19
|
+
|
|
20
|
+
/** Where the JSON-RPC endpoint lives, relative to the host origin. */
|
|
21
|
+
export const A2A_PATH = '/a2a';
|
|
22
|
+
|
|
23
|
+
/** The card's canonical location, as A2A 1.0 defines it. */
|
|
24
|
+
export const CARD_PATH = '/a2a/.well-known/agent-card.json';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Legacy alias for the card path.
|
|
28
|
+
*
|
|
29
|
+
* 0.3 clients (and a few gateways that never updated) probe `agent.json`. It
|
|
30
|
+
* costs one route to serve both, and refusing would make a working deployment
|
|
31
|
+
* look broken to an older peer.
|
|
32
|
+
*/
|
|
33
|
+
export const CARD_ALIAS_PATH = '/a2a/.well-known/agent.json';
|
|
34
|
+
|
|
35
|
+
/** Media types a 1.0 peer expects on the two input/output mode fields. */
|
|
36
|
+
const DEFAULT_INPUT_MODES = Object.freeze(['text/plain']);
|
|
37
|
+
const DEFAULT_OUTPUT_MODES = Object.freeze(['text/plain']);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The subset of a plugin config the card reads.
|
|
41
|
+
*
|
|
42
|
+
* Declared apart from the full §3 config because the card builder needs three
|
|
43
|
+
* facts only: whether streaming is served at all (it changes what the card
|
|
44
|
+
* promises), the public endpoint to advertise, and the agent identity strings.
|
|
45
|
+
* A wider type here would let the card quietly start depending on more config.
|
|
46
|
+
*
|
|
47
|
+
* @typedef {object} CardConfig
|
|
48
|
+
* @property {CardServerConfig} [server] - the server slice of the config.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The server-config fields the card cares about.
|
|
53
|
+
*
|
|
54
|
+
* @typedef {object} CardServerConfig
|
|
55
|
+
* @property {string} [publicUrl] - fully qualified endpoint; empty means derive from origin.
|
|
56
|
+
* @property {boolean} [streaming] - whether SendStreamingMessage is served at all.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One skill as the registry exposes it and the card publishes it.
|
|
61
|
+
*
|
|
62
|
+
* Note what is NOT here: the run function. The card is a public JSON document,
|
|
63
|
+
* so the callable body never travels with it — a peer names the skill id and the
|
|
64
|
+
* server dispatches. Listing it would either serialize as undefined or leak a
|
|
65
|
+
* function reference into a document that is served to anyone.
|
|
66
|
+
*
|
|
67
|
+
* @typedef {object} CardSkill
|
|
68
|
+
* @property {string} id - the value a caller puts in message.metadata.skill.
|
|
69
|
+
* @property {string} [name] - display name; falls back to the id.
|
|
70
|
+
* @property {string} [description]
|
|
71
|
+
* @property {string[]} [tags] - free-form grouping, for example a streaming marker.
|
|
72
|
+
* @property {string[]} [examples] - sample prompts, shown in a discovery UI.
|
|
73
|
+
* @property {string[]} [inputModes] - defaults to text/plain when absent.
|
|
74
|
+
* @property {string[]} [outputModes] - defaults to text/plain when absent.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* What the card builder accepts.
|
|
79
|
+
*
|
|
80
|
+
* @typedef {object} CardOptions
|
|
81
|
+
* @property {CardConfig} [config] - normalized plugin config.
|
|
82
|
+
* @property {{list: () => CardSkill[]}} [skills] - skill registry.
|
|
83
|
+
* @property {string} [url] - absolute endpoint URL, already ending in /a2a.
|
|
84
|
+
* @property {string} [name] - agent display name.
|
|
85
|
+
* @property {string} [description] - agent description.
|
|
86
|
+
* @property {string} [version] - plugin version.
|
|
87
|
+
* @property {object} [profile] - layer-B profile extension, or absent.
|
|
88
|
+
*/
|
|
89
|
+
/**
|
|
90
|
+
* Resolve the public endpoint URL advertised in the card and its interfaces.
|
|
91
|
+
*
|
|
92
|
+
* Per §3 of the contract, `config.server.publicUrl` is the *fully qualified*
|
|
93
|
+
* endpoint a peer should call — an operator behind a gateway writes the whole
|
|
94
|
+
* `/a2a/me` path there, because only they know what the gateway rewrote. An
|
|
95
|
+
* empty setting instead means "derive it": the webserver's origin plus `/a2a`,
|
|
96
|
+
* which is right for a direct bind and for localhost debugging. Appending `/a2a`
|
|
97
|
+
* to a configured publicUrl would produce `/a2a/me/a2a`, so the two cases are
|
|
98
|
+
* kept distinct rather than folded into one "base" concept.
|
|
99
|
+
*
|
|
100
|
+
* @param {{config?: CardConfig, origin?: () => string|undefined}} input - resolution inputs.
|
|
101
|
+
* @returns {string} absolute endpoint URL without a trailing slash, or `''` when unknown.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveAgentUrl({ config, origin } = {}) {
|
|
104
|
+
const configured = typeof config?.server?.publicUrl === 'string' ? config.server.publicUrl.trim() : '';
|
|
105
|
+
if (configured !== '') return configured.replace(/\/+$/, '');
|
|
106
|
+
if (typeof origin !== 'function') return '';
|
|
107
|
+
try {
|
|
108
|
+
const value = origin();
|
|
109
|
+
if (typeof value !== 'string' || value.trim() === '') return '';
|
|
110
|
+
return `${value.trim().replace(/\/+$/, '')}${A2A_PATH}`;
|
|
111
|
+
} catch {
|
|
112
|
+
// getOrigin() throws before the listener binds; a card built that early can
|
|
113
|
+
// still be useful to the operating human, so return an empty url instead.
|
|
114
|
+
return '';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Project one registry skill into the card's `skills[]` entry.
|
|
120
|
+
*
|
|
121
|
+
* Only declared metadata is copied. `run` is dropped on purpose: the card is a
|
|
122
|
+
* public document, and shipping a function reference would serialize as
|
|
123
|
+
* `undefined` in JSON and silently change the shape under test.
|
|
124
|
+
*
|
|
125
|
+
* @param {CardSkill} skill - registry skill.
|
|
126
|
+
* @returns {object|undefined} the card entry, or `undefined` for a malformed skill.
|
|
127
|
+
*/
|
|
128
|
+
export function buildSkillEntry(skill) {
|
|
129
|
+
if (skill === null || typeof skill !== 'object') return undefined;
|
|
130
|
+
const id = typeof skill.id === 'string' ? skill.id.trim() : '';
|
|
131
|
+
if (id === '') return undefined;
|
|
132
|
+
const entry = {
|
|
133
|
+
id,
|
|
134
|
+
name: typeof skill.name === 'string' && skill.name !== '' ? skill.name : id,
|
|
135
|
+
description: typeof skill.description === 'string' ? skill.description : '',
|
|
136
|
+
};
|
|
137
|
+
if (Array.isArray(skill.tags) && skill.tags.length > 0) entry.tags = skill.tags.map(String);
|
|
138
|
+
if (Array.isArray(skill.examples) && skill.examples.length > 0) entry.examples = skill.examples.map(String);
|
|
139
|
+
if (Array.isArray(skill.inputModes) && skill.inputModes.length > 0) entry.inputModes = skill.inputModes.map(String);
|
|
140
|
+
if (Array.isArray(skill.outputModes) && skill.outputModes.length > 0) entry.outputModes = skill.outputModes.map(String);
|
|
141
|
+
return entry;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Build the agent card document.
|
|
146
|
+
*
|
|
147
|
+
* `capabilities` reports what this deployment actually does. `streaming` follows
|
|
148
|
+
* config because a deployment with streaming off answers `SendStreamingMessage`
|
|
149
|
+
* with `-32601`, and a card claiming otherwise would make every 1.0 client pick
|
|
150
|
+
* the wrong method first. `pushNotifications` and `stateTransitionHistory` stay
|
|
151
|
+
* false: neither is implemented, and a peer that trusts a true here will wait
|
|
152
|
+
* forever for pushes that never arrive.
|
|
153
|
+
*
|
|
154
|
+
* `profile` is the emergent identity (layer B): who this instance is across the
|
|
155
|
+
* groups it belongs to. It is derived at read time from memberships, so it can
|
|
156
|
+
* never be stale — which is why it is a parameter here rather than stored state.
|
|
157
|
+
*
|
|
158
|
+
* @param {CardOptions} input - card inputs.
|
|
159
|
+
* @returns {object} the card document, ready to serialize as JSON.
|
|
160
|
+
*/
|
|
161
|
+
export function buildCard({ config, skills, url, name, description, version, profile } = {}) {
|
|
162
|
+
const endpoint = typeof url === 'string' && url.trim() !== '' ? url.trim().replace(/\/+$/, '') : '';
|
|
163
|
+
const streaming = config?.server?.streaming !== false;
|
|
164
|
+
const registrySkills = typeof skills?.list === 'function' ? skills.list() : [];
|
|
165
|
+
const entries = [];
|
|
166
|
+
if (Array.isArray(registrySkills)) {
|
|
167
|
+
for (const skill of registrySkills) {
|
|
168
|
+
const entry = buildSkillEntry(skill);
|
|
169
|
+
if (entry !== undefined) entries.push(entry);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// The profile supplies the identity when the operator did not hard-code one:
|
|
173
|
+
// `name` falls back to how this person names themselves across groups, and
|
|
174
|
+
// `description` to an auto-generated line naming their busiest roles.
|
|
175
|
+
const resolvedName = typeof name === 'string' && name !== ''
|
|
176
|
+
? name
|
|
177
|
+
: (typeof profile?.name === 'string' && profile.name !== '' ? profile.name : 'dsh-a2a');
|
|
178
|
+
const resolvedDescription = typeof description === 'string' && description !== ''
|
|
179
|
+
? description
|
|
180
|
+
: (typeof profile?.headline === 'string' && profile.headline !== ''
|
|
181
|
+
? profile.headline
|
|
182
|
+
: 'Exposes a DSH Session as an A2A 1.0 agent and carries invite-only collaboration groups.');
|
|
183
|
+
|
|
184
|
+
const card = {
|
|
185
|
+
name: resolvedName,
|
|
186
|
+
description: resolvedDescription,
|
|
187
|
+
url: endpoint,
|
|
188
|
+
version: typeof version === 'string' && version !== '' ? version : '0.1.0',
|
|
189
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
190
|
+
preferredTransport: 'JSONRPC',
|
|
191
|
+
capabilities: {
|
|
192
|
+
streaming,
|
|
193
|
+
pushNotifications: false,
|
|
194
|
+
stateTransitionHistory: false,
|
|
195
|
+
},
|
|
196
|
+
defaultInputModes: [...DEFAULT_INPUT_MODES],
|
|
197
|
+
defaultOutputModes: [...DEFAULT_OUTPUT_MODES],
|
|
198
|
+
supportedInterfaces: [
|
|
199
|
+
{
|
|
200
|
+
url: endpoint,
|
|
201
|
+
protocolBinding: 'JSONRPC',
|
|
202
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
skills: entries,
|
|
206
|
+
};
|
|
207
|
+
// Vendor extension, preserved verbatim by an A2A gateway and ignored by a peer
|
|
208
|
+
// that does not understand it. Carries the per-group identity so a caller can
|
|
209
|
+
// tell "this is the developer on invoice, the product owner on contracts".
|
|
210
|
+
if (profile !== undefined && profile !== null && Array.isArray(profile.groups) && profile.groups.length > 0) {
|
|
211
|
+
const extension = { groups: profile.groups };
|
|
212
|
+
if (typeof profile.headline === 'string' && profile.headline !== '') extension.headline = profile.headline;
|
|
213
|
+
if (Array.isArray(profile.tags) && profile.tags.length > 0) extension.tags = profile.tags;
|
|
214
|
+
card['x-agent-profiles'] = extension;
|
|
215
|
+
}
|
|
216
|
+
return card;
|
|
217
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aggregate cached rosters into colleague cards.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS ITS OWN MODULE
|
|
5
|
+
*
|
|
6
|
+
* It was written inline in `index.js`, where it could only be exercised by mounting the whole
|
|
7
|
+
* plugin. It is a pure function over data — rosters in, cards out — and the questions worth
|
|
8
|
+
* testing ("does one person in two groups become two cards?", "does an absent role stay
|
|
9
|
+
* absent?") have nothing to do with the DSH runtime. Moving it also puts it in `core/`, which
|
|
10
|
+
* is where the boundary says pure logic belongs.
|
|
11
|
+
*
|
|
12
|
+
* WHAT A CARD IS
|
|
13
|
+
*
|
|
14
|
+
* A person's own description, collected across every group this instance shares with them:
|
|
15
|
+
*
|
|
16
|
+
* name the display name they use
|
|
17
|
+
* headline the first role they declared, in group order
|
|
18
|
+
* tags every tag they declared, deduplicated
|
|
19
|
+
* roles all their declared roles, because layer A is per-group
|
|
20
|
+
* groups which of MY groups they are in — a fact the server verified
|
|
21
|
+
* online whether any roster saw them recently
|
|
22
|
+
* isSelf whether this is the reader's own card, which is the only editable one
|
|
23
|
+
*
|
|
24
|
+
* WHAT IT REFUSES TO DO
|
|
25
|
+
*
|
|
26
|
+
* Invent identity. A tag is self-declared, so a card says "tags", never "department": one
|
|
27
|
+
* person writing 测试 in one small group does not make them the tester, it makes them
|
|
28
|
+
* somebody who once took the testing role there. An absent role stays empty rather than
|
|
29
|
+
* becoming "member", because a placeholder reads as knowledge.
|
|
30
|
+
*
|
|
31
|
+
* @module dsh-a2a/colleague-cards
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Most cards one snapshot may carry, so a large host cannot inflate every page render. */
|
|
35
|
+
const MAX_CARDS = 100;
|
|
36
|
+
|
|
37
|
+
/** Most tags per card. A person with forty tags has a data problem, not a profile. */
|
|
38
|
+
const MAX_TAGS = 8;
|
|
39
|
+
|
|
40
|
+
/** Most roles per card, for the same reason. */
|
|
41
|
+
const MAX_ROLES = 4;
|
|
42
|
+
|
|
43
|
+
/** Most groups named per card. */
|
|
44
|
+
const MAX_GROUPS = 20;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build cards from membership records.
|
|
48
|
+
*
|
|
49
|
+
* @param {object[]} records - membership records, each possibly carrying a cached `members` roster.
|
|
50
|
+
* @returns {object[]} cards, most-connected first.
|
|
51
|
+
*/
|
|
52
|
+
export function aggregateColleagueCards(records) {
|
|
53
|
+
if (!Array.isArray(records)) return [];
|
|
54
|
+
|
|
55
|
+
/** personId (or `name:<display>`) → accumulating card. */
|
|
56
|
+
const byPerson = new Map();
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Which roster rows are mine.
|
|
60
|
+
*
|
|
61
|
+
* A membership record names the member id this instance holds in that group, so the row
|
|
62
|
+
* with the same id is me. Names are a fallback only for a roster that carries no ids at
|
|
63
|
+
* all, because a display name is not unique.
|
|
64
|
+
*/
|
|
65
|
+
const selfIds = new Set();
|
|
66
|
+
const selfNames = new Set();
|
|
67
|
+
for (const record of records) {
|
|
68
|
+
if (record === null || typeof record !== 'object') continue;
|
|
69
|
+
if (typeof record.personId === 'string' && record.personId !== '') selfIds.add(record.personId);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const record of records) {
|
|
73
|
+
if (record === null || typeof record !== 'object') continue;
|
|
74
|
+
const roster = Array.isArray(record.members) ? record.members : [];
|
|
75
|
+
const groupName = typeof record.groupName === 'string' && record.groupName !== ''
|
|
76
|
+
? record.groupName
|
|
77
|
+
: (typeof record.groupId === 'string' ? record.groupId : '');
|
|
78
|
+
|
|
79
|
+
for (const member of roster) {
|
|
80
|
+
if (member === null || typeof member !== 'object') continue;
|
|
81
|
+
const name = typeof member.displayName === 'string' ? member.displayName.trim() : '';
|
|
82
|
+
// A row with no name cannot become a card: "unnamed person" is not a colleague.
|
|
83
|
+
if (name === '') continue;
|
|
84
|
+
|
|
85
|
+
const key = typeof member.personId === 'string' && member.personId !== ''
|
|
86
|
+
? member.personId
|
|
87
|
+
: `name:${name}`;
|
|
88
|
+
|
|
89
|
+
let card = byPerson.get(key);
|
|
90
|
+
if (card === undefined) {
|
|
91
|
+
card = {
|
|
92
|
+
personId: typeof member.personId === 'string' ? member.personId : '',
|
|
93
|
+
name,
|
|
94
|
+
tags: [],
|
|
95
|
+
roles: [],
|
|
96
|
+
groups: [],
|
|
97
|
+
online: false,
|
|
98
|
+
// Set from the ledger's own member id, never from the name: two people can share a
|
|
99
|
+
// display name, and mistaking somebody else's row for mine would offer an edit that
|
|
100
|
+
// changes the wrong person's profile.
|
|
101
|
+
isSelf: selfIds.has(member.personId) || (typeof member.personId !== 'string' && selfNames.has(name)),
|
|
102
|
+
};
|
|
103
|
+
byPerson.set(key, card);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Any sighting counts: rosters are fetched at different times, and a person seen
|
|
107
|
+
// online in one group is online.
|
|
108
|
+
if (member.online === true) card.online = true;
|
|
109
|
+
|
|
110
|
+
// Layer A is per-group, so the card keeps the union rather than picking one answer.
|
|
111
|
+
const about = typeof member.roleAbout === 'string' ? member.roleAbout.trim() : '';
|
|
112
|
+
if (about !== '' && !card.roles.includes(about)) card.roles.push(about);
|
|
113
|
+
|
|
114
|
+
for (const tag of Array.isArray(member.tags) ? member.tags : []) {
|
|
115
|
+
if (typeof tag === 'string' && tag !== '' && !card.tags.includes(tag)) card.tags.push(tag);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (groupName !== '' && !card.groups.includes(groupName)) card.groups.push(groupName);
|
|
119
|
+
|
|
120
|
+
// A roster row that carries my member id confirms which card is mine. Doing it here
|
|
121
|
+
// rather than only at creation also covers the case where the first roster seen for a
|
|
122
|
+
// group arrived without ids.
|
|
123
|
+
if (selfIds.has(member.personId)) card.isSelf = true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const cards = [...byPerson.values()].map((card) => ({
|
|
128
|
+
personId: card.personId,
|
|
129
|
+
name: card.name,
|
|
130
|
+
isSelf: card.isSelf === true,
|
|
131
|
+
// The first role they declared. Empty when they never declared one — deliberately not a
|
|
132
|
+
// placeholder, because a placeholder reads as knowledge.
|
|
133
|
+
headline: card.roles[0] ?? '',
|
|
134
|
+
tags: card.tags.slice(0, MAX_TAGS),
|
|
135
|
+
roles: card.roles.slice(0, MAX_ROLES),
|
|
136
|
+
groups: card.groups.slice(0, MAX_GROUPS),
|
|
137
|
+
online: card.online,
|
|
138
|
+
}));
|
|
139
|
+
|
|
140
|
+
// Mine first, always. Every other card is read-only; mine is the one I own, and the
|
|
141
|
+
// question "what do others see when they look at me" comes before "who else is around".
|
|
142
|
+
// After that, most connected first — somebody in three of my groups is a likelier target
|
|
143
|
+
// than somebody I share one group with — with alphabetical ties so the order is stable.
|
|
144
|
+
cards.sort((left, right) => Number(right.isSelf) - Number(left.isSelf)
|
|
145
|
+
|| right.groups.length - left.groups.length
|
|
146
|
+
|| left.name.localeCompare(right.name));
|
|
147
|
+
|
|
148
|
+
return cards.slice(0, MAX_CARDS);
|
|
149
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for dsh-a2a.
|
|
3
|
+
*
|
|
4
|
+
* Config is validated here rather than by a Cordis schema, for the same
|
|
5
|
+
* self-containment reason as the rest of this plugin: a `Config` export would
|
|
6
|
+
* require `schemastery`, and a profile plugin is loaded by plain Node
|
|
7
|
+
* resolution, so bare `@deepseek-ai/...` specifiers would not resolve.
|
|
8
|
+
*
|
|
9
|
+
* Every malformed value falls back to its default instead of throwing. A plugin
|
|
10
|
+
* that refuses to mount because one number was a string is a plugin an operator
|
|
11
|
+
* cannot diagnose; one that mounts with a logged fallback is one they can.
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-a2a/config
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Delivery presentations: how an inbound A2A message shows up in a Session. */
|
|
17
|
+
export const PRESENTATIONS = Object.freeze(['notice', 'bubble']);
|
|
18
|
+
|
|
19
|
+
/** Delivery modes accepted by the deliverer. */
|
|
20
|
+
export const DELIVERY_MODES = Object.freeze(['queue', 'steer', 'interrupt', 'inbox']);
|
|
21
|
+
|
|
22
|
+
/** The A2A wire versions this plugin speaks. */
|
|
23
|
+
export const PROTOCOL_VERSIONS = Object.freeze(['1.0']);
|
|
24
|
+
|
|
25
|
+
/** Every default in one place, so a test and an operator read the same numbers. */
|
|
26
|
+
export const DEFAULT_CONFIG = Object.freeze({
|
|
27
|
+
enabled: true,
|
|
28
|
+
/**
|
|
29
|
+
* Identity in the agent card.
|
|
30
|
+
*
|
|
31
|
+
* Both fields are OPTIONAL and both have a derived default: with nothing set,
|
|
32
|
+
* the card names this instance after how it names itself across groups, and
|
|
33
|
+
* describes it with the roles it declared there (layer B). An operator sets
|
|
34
|
+
* these only when they want an identity that does not move as memberships
|
|
35
|
+
* change — which is a legitimate want, not the default.
|
|
36
|
+
*/
|
|
37
|
+
agent: Object.freeze({
|
|
38
|
+
name: '',
|
|
39
|
+
description: '',
|
|
40
|
+
version: '',
|
|
41
|
+
}),
|
|
42
|
+
server: Object.freeze({
|
|
43
|
+
enabled: true,
|
|
44
|
+
publicUrl: '',
|
|
45
|
+
token: '',
|
|
46
|
+
bindSessionId: 'self',
|
|
47
|
+
maxBodyBytes: 1048576,
|
|
48
|
+
taskTimeoutMs: 600000,
|
|
49
|
+
streaming: true,
|
|
50
|
+
skills: Object.freeze({ session: true, group: true }),
|
|
51
|
+
}),
|
|
52
|
+
client: Object.freeze({
|
|
53
|
+
enabled: true,
|
|
54
|
+
apiKey: '',
|
|
55
|
+
gatewayBase: '',
|
|
56
|
+
protocolVersion: '1.0',
|
|
57
|
+
forwardProto: '',
|
|
58
|
+
cardTtlMs: 300000,
|
|
59
|
+
timeoutMs: 120000,
|
|
60
|
+
reconnectAttempts: 3,
|
|
61
|
+
streamIdleMs: 120000,
|
|
62
|
+
}),
|
|
63
|
+
groups: Object.freeze({
|
|
64
|
+
enabled: true,
|
|
65
|
+
home: '',
|
|
66
|
+
// Hours an invite stays valid.
|
|
67
|
+
//
|
|
68
|
+
// `null` makes invites permanent by default; a number keeps them bounded. There
|
|
69
|
+
// is no use counter — revoking a link is how an owner stops it, which is more
|
|
70
|
+
// precise than waiting for a quota to run out.
|
|
71
|
+
inviteExpiresInHours: 168,
|
|
72
|
+
maxPostChars: 32000,
|
|
73
|
+
maxPostsPerMinute: 20,
|
|
74
|
+
retentionDays: 30,
|
|
75
|
+
wakeMembers: true,
|
|
76
|
+
publicUrl: '',
|
|
77
|
+
agentName: '',
|
|
78
|
+
gatewayBase: '',
|
|
79
|
+
// Whether THIS instance hosts groups itself, instead of a standalone agent-groups
|
|
80
|
+
// server. Off by default: the normal shape is one shared host behind the gateway
|
|
81
|
+
// and every DSH instance as a member. Turning it on makes this instance accept
|
|
82
|
+
// group/create locally, which is only useful for a single-machine setup.
|
|
83
|
+
embedded: false,
|
|
84
|
+
}),
|
|
85
|
+
delivery: Object.freeze({
|
|
86
|
+
presentation: 'notice',
|
|
87
|
+
defaultMode: 'queue',
|
|
88
|
+
hopBudget: 16,
|
|
89
|
+
dedupeWindowMs: 600000,
|
|
90
|
+
deliveryTimeoutMs: 120000,
|
|
91
|
+
}),
|
|
92
|
+
tools: true,
|
|
93
|
+
promptSection: true,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Validate and fill in a raw plugin config.
|
|
98
|
+
*
|
|
99
|
+
* Unknown keys are dropped: a config that carries a key this version does not
|
|
100
|
+
* understand is either from a newer release or a typo, and forwarding either
|
|
101
|
+
* into the runtime would let a typo silently become behaviour.
|
|
102
|
+
*
|
|
103
|
+
* @param {object} [raw] - plugin configuration from the profile patch.
|
|
104
|
+
* @returns {typeof DEFAULT_CONFIG} the normalized settings.
|
|
105
|
+
*/
|
|
106
|
+
export function normalizeConfig(raw) {
|
|
107
|
+
const source = isPlainObject(raw) ? raw : {};
|
|
108
|
+
const defaults = DEFAULT_CONFIG;
|
|
109
|
+
const agent = isPlainObject(source.agent) ? source.agent : {};
|
|
110
|
+
const server = isPlainObject(source.server) ? source.server : {};
|
|
111
|
+
const client = isPlainObject(source.client) ? source.client : {};
|
|
112
|
+
const groups = isPlainObject(source.groups) ? source.groups : {};
|
|
113
|
+
const delivery = isPlainObject(source.delivery) ? source.delivery : {};
|
|
114
|
+
const skills = isPlainObject(server.skills) ? server.skills : {};
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
enabled: bool(source.enabled, defaults.enabled),
|
|
118
|
+
agent: {
|
|
119
|
+
name: text(agent.name, defaults.agent.name),
|
|
120
|
+
description: text(agent.description, defaults.agent.description),
|
|
121
|
+
version: text(agent.version, defaults.agent.version),
|
|
122
|
+
},
|
|
123
|
+
server: {
|
|
124
|
+
enabled: bool(server.enabled, defaults.server.enabled),
|
|
125
|
+
publicUrl: text(server.publicUrl, defaults.server.publicUrl),
|
|
126
|
+
token: text(server.token, defaults.server.token),
|
|
127
|
+
bindSessionId: text(server.bindSessionId, defaults.server.bindSessionId),
|
|
128
|
+
maxBodyBytes: integer(server.maxBodyBytes, defaults.server.maxBodyBytes, 1024, 64 * 1024 * 1024),
|
|
129
|
+
taskTimeoutMs: integer(server.taskTimeoutMs, defaults.server.taskTimeoutMs, 1000, 24 * 3600 * 1000),
|
|
130
|
+
streaming: bool(server.streaming, defaults.server.streaming),
|
|
131
|
+
skills: {
|
|
132
|
+
session: bool(skills.session, defaults.server.skills.session),
|
|
133
|
+
group: bool(skills.group, defaults.server.skills.group),
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
client: {
|
|
137
|
+
enabled: bool(client.enabled, defaults.client.enabled),
|
|
138
|
+
apiKey: text(client.apiKey, defaults.client.apiKey),
|
|
139
|
+
gatewayBase: stripTrailingSlash(text(client.gatewayBase, defaults.client.gatewayBase)),
|
|
140
|
+
protocolVersion: oneOf(client.protocolVersion, PROTOCOL_VERSIONS, defaults.client.protocolVersion),
|
|
141
|
+
// Local http development: the gateway advertises `https` on the card unless
|
|
142
|
+
// a proxy says otherwise, so this header is what makes the card honest.
|
|
143
|
+
forwardProto: oneOf(text(client.forwardProto, ''), ['', 'http', 'https'], ''),
|
|
144
|
+
cardTtlMs: integer(client.cardTtlMs, defaults.client.cardTtlMs, 0, 24 * 3600 * 1000),
|
|
145
|
+
timeoutMs: integer(client.timeoutMs, defaults.client.timeoutMs, 1000, 24 * 3600 * 1000),
|
|
146
|
+
reconnectAttempts: integer(client.reconnectAttempts, defaults.client.reconnectAttempts, 0, 20),
|
|
147
|
+
streamIdleMs: integer(client.streamIdleMs, defaults.client.streamIdleMs, 1000, 24 * 3600 * 1000),
|
|
148
|
+
},
|
|
149
|
+
groups: {
|
|
150
|
+
enabled: bool(groups.enabled, defaults.groups.enabled),
|
|
151
|
+
home: text(groups.home, defaults.groups.home),
|
|
152
|
+
// `null` = invites never expire by default; a number = that many hours.
|
|
153
|
+
inviteExpiresInHours: groups.inviteExpiresInHours === null
|
|
154
|
+
? null
|
|
155
|
+
: integer(groups.inviteExpiresInHours, defaults.groups.inviteExpiresInHours, 1, 24 * 365 * 10),
|
|
156
|
+
maxPostChars: integer(groups.maxPostChars, defaults.groups.maxPostChars, 200, 1024 * 1024),
|
|
157
|
+
maxPostsPerMinute: integer(groups.maxPostsPerMinute, defaults.groups.maxPostsPerMinute, 1, 10000),
|
|
158
|
+
retentionDays: integer(groups.retentionDays, defaults.groups.retentionDays, 1, 3650),
|
|
159
|
+
wakeMembers: bool(groups.wakeMembers, defaults.groups.wakeMembers),
|
|
160
|
+
publicUrl: stripTrailingSlash(text(groups.publicUrl, defaults.groups.publicUrl)),
|
|
161
|
+
agentName: text(groups.agentName, defaults.groups.agentName),
|
|
162
|
+
gatewayBase: stripTrailingSlash(text(groups.gatewayBase, defaults.groups.gatewayBase)),
|
|
163
|
+
embedded: bool(groups.embedded, defaults.groups.embedded),
|
|
164
|
+
},
|
|
165
|
+
delivery: {
|
|
166
|
+
presentation: oneOf(delivery.presentation, PRESENTATIONS, defaults.delivery.presentation),
|
|
167
|
+
defaultMode: oneOf(delivery.defaultMode, DELIVERY_MODES, defaults.delivery.defaultMode),
|
|
168
|
+
hopBudget: integer(delivery.hopBudget, defaults.delivery.hopBudget, 1, 64),
|
|
169
|
+
dedupeWindowMs: integer(delivery.dedupeWindowMs, defaults.delivery.dedupeWindowMs, 0, 24 * 3600 * 1000),
|
|
170
|
+
deliveryTimeoutMs: integer(
|
|
171
|
+
delivery.deliveryTimeoutMs,
|
|
172
|
+
defaults.delivery.deliveryTimeoutMs,
|
|
173
|
+
1000,
|
|
174
|
+
24 * 3600 * 1000,
|
|
175
|
+
),
|
|
176
|
+
},
|
|
177
|
+
tools: bool(source.tools, defaults.tools),
|
|
178
|
+
promptSection: bool(source.promptSection, defaults.promptSection),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The directory that owns this plugin's durable state.
|
|
184
|
+
*
|
|
185
|
+
* `<DSH_HOME>/a2a` by default: a plugin's state belongs beside the Sessions it
|
|
186
|
+
* belongs to, so a profile copied to another machine carries its groups with it.
|
|
187
|
+
* A configured `groups.home` wins so a deployment can put the state on a shared
|
|
188
|
+
* volume.
|
|
189
|
+
*
|
|
190
|
+
* @param {object} settings - normalized config.
|
|
191
|
+
* @param {NodeJS.ProcessEnv} [env] - environment to read `DSH_HOME` from.
|
|
192
|
+
* @returns {string} the state directory.
|
|
193
|
+
*/
|
|
194
|
+
export function resolveHome(settings, env = process.env) {
|
|
195
|
+
const configured = settings?.groups?.home ?? '';
|
|
196
|
+
if (configured !== '') return configured;
|
|
197
|
+
const home = typeof env.DSH_HOME === 'string' && env.DSH_HOME !== '' ? env.DSH_HOME : undefined;
|
|
198
|
+
const base = home ?? process.cwd();
|
|
199
|
+
return `${base.replace(/[\\/]+$/, '')}/a2a`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Coerce to a boolean, falling back when absent. */
|
|
203
|
+
function bool(value, fallback) {
|
|
204
|
+
return typeof value === 'boolean' ? value : fallback;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Coerce to a trimmed string, falling back when absent or blank. */
|
|
208
|
+
function text(value, fallback) {
|
|
209
|
+
if (typeof value !== 'string') return fallback;
|
|
210
|
+
return value;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Coerce to an integer inside `[min, max]`, falling back when unusable. */
|
|
214
|
+
function integer(value, fallback, min, max) {
|
|
215
|
+
const number = typeof value === 'number' ? value : Number(value);
|
|
216
|
+
if (!Number.isFinite(number)) return fallback;
|
|
217
|
+
const rounded = Math.trunc(number);
|
|
218
|
+
if (rounded < min || rounded > max) return fallback;
|
|
219
|
+
return rounded;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Coerce to one of `allowed`, falling back otherwise. */
|
|
223
|
+
function oneOf(value, allowed, fallback) {
|
|
224
|
+
return allowed.includes(value) ? value : fallback;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Drop trailing slashes so two spellings of the same base compare equal. */
|
|
228
|
+
function stripTrailingSlash(value) {
|
|
229
|
+
return value.replace(/\/+$/, '');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Whether a value is a plain JSON object (not an array, not null). */
|
|
233
|
+
function isPlainObject(value) {
|
|
234
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
235
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Work out this instance's own A2A address, so a member who joins can be reached.
|
|
3
|
+
*
|
|
4
|
+
* THE PROBLEM THIS SOLVES
|
|
5
|
+
*
|
|
6
|
+
* The `endpoint` field on a member row has existed since the beginning and has always been empty,
|
|
7
|
+
* because `join` only took an endpoint the caller passed in — and no caller knows their own
|
|
8
|
+
* address. So "reach that person directly" was never possible, and the colleague card had no
|
|
9
|
+
* address to show.
|
|
10
|
+
*
|
|
11
|
+
* WHY THE ADDRESS IS COMPOSED, NOT DISCOVERED
|
|
12
|
+
*
|
|
13
|
+
* Members reach each other through the gateway, whose convention is already fixed by configuration:
|
|
14
|
+
*
|
|
15
|
+
* <gatewayBase>/<agentName>
|
|
16
|
+
* http://gw:3000/a2a/zhangsan
|
|
17
|
+
*
|
|
18
|
+
* That is exactly the shape the invite link already encodes (it carries `gw` and `agent`), so
|
|
19
|
+
* composing it here does not invent a convention — it reuses the one that already lets a colleague
|
|
20
|
+
* join.
|
|
21
|
+
*
|
|
22
|
+
* WHERE IT REFUSES TO GUESS
|
|
23
|
+
*
|
|
24
|
+
* When either half is unconfigured, the answer is `undefined`, not a plausible-looking URL. A wrong
|
|
25
|
+
* endpoint is worse than none: a caller that tries to reach it waits for a timeout and concludes the
|
|
26
|
+
* whole feature is broken, while an absent one lets the caller say "no address, use the group".
|
|
27
|
+
*
|
|
28
|
+
* @module dsh-a2a/endpoint
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Characters allowed in one path segment of an agent name. */
|
|
32
|
+
const SAFE_SEGMENT = /^[A-Za-z0-9._~-]+$/u;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Join a base URL and an agent name into an endpoint.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} input
|
|
38
|
+
* @param {string} [input.gatewayBase] the A2A gateway base, e.g. `http://gw:3000/a2a`.
|
|
39
|
+
* @param {string} [input.agentName] this instance's name at the gateway, e.g. `zhangsan`.
|
|
40
|
+
* @returns {string|undefined} the endpoint, or undefined when either half is missing.
|
|
41
|
+
*/
|
|
42
|
+
export function composeEndpoint({ gatewayBase, agentName } = {}) {
|
|
43
|
+
const base = typeof gatewayBase === 'string' ? gatewayBase.trim() : '';
|
|
44
|
+
const name = typeof agentName === 'string' ? agentName.trim() : '';
|
|
45
|
+
|
|
46
|
+
// Both halves are required. Half a URL is not a shorter answer, it is a wrong one.
|
|
47
|
+
if (base === '' || name === '') return undefined;
|
|
48
|
+
|
|
49
|
+
// The name becomes a path segment, so it has to survive being one. A name containing `/` or `?`
|
|
50
|
+
// would silently truncate or misroute, and the failure would surface as "colleague unreachable"
|
|
51
|
+
// long after the join that caused it.
|
|
52
|
+
if (!SAFE_SEGMENT.test(name)) return undefined;
|
|
53
|
+
|
|
54
|
+
// A base with a trailing slash and a name with a leading one would produce `//`, which some
|
|
55
|
+
// gateways treat as a different path.
|
|
56
|
+
const trimmedBase = base.replace(/\/+$/u, '');
|
|
57
|
+
return `${trimmedBase}/${name}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The endpoint for this host, from its normalized config.
|
|
62
|
+
*
|
|
63
|
+
* @param {object} config - normalized config (`config.groups`).
|
|
64
|
+
* @returns {string|undefined} the endpoint, or undefined when not configured.
|
|
65
|
+
*/
|
|
66
|
+
export function ownEndpointFromConfig(config) {
|
|
67
|
+
const groups = config?.groups ?? {};
|
|
68
|
+
return composeEndpoint({ gatewayBase: groups.gatewayBase, agentName: groups.agentName });
|
|
69
|
+
}
|