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.
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Layer B: the outward-facing profile, aggregated from group memberships.
3
+ *
4
+ * The whole point is that nobody maintains this. A person joins groups, says what
5
+ * they do in each one (layer A), and their card — what any peer reads when it runs
6
+ * `a2a_discover` — reflects that automatically. Asking people to fill in a profile
7
+ * form would produce stale text within a month; aggregating from memberships
8
+ * cannot go stale, because it is derived at read time.
9
+ *
10
+ * Two inputs, both already on disk:
11
+ *
12
+ * - `groups.js` knows, for each group this instance hosts, what a member said
13
+ * about themselves (`roleAbout` / `tags`).
14
+ * - `memberships.js` knows which groups this instance belongs to, with which
15
+ * credential, and whether each is local.
16
+ *
17
+ * This module is the join of those two, plus the rule that turns them into prose.
18
+ * It has no I/O of its own: the caller passes the stores in.
19
+ *
20
+ * @module dsh-a2a/profile
21
+ */
22
+
23
+ /** How many roles to name in the auto-generated one-liner before summarising. */
24
+ const HEADLINE_LIMIT = 3;
25
+
26
+ /**
27
+ * Build this instance's profile from its memberships.
28
+ *
29
+ * Deterministic and side-effect free, so it can be called on every card read
30
+ * without cost or surprise: a card is fetched on every `a2a_discover`, and a
31
+ * profile that did I/O would make discovery slow and failure-prone.
32
+ *
33
+ * @param {object} input - sources.
34
+ * @param {object} [input.identities] - the membership ledger.
35
+ * @param {object} [input.store] - the group store (for hosted groups' roles).
36
+ * @param {object} [input.config] - normalized config, for explicit overrides.
37
+ * @returns {{name: string|undefined, headline: string|undefined, groups: object[], tags: string[]}} the profile.
38
+ */
39
+ export function buildProfile({ identities, store, config } = {}) {
40
+ const records = typeof identities?.list === 'function' ? identities.list() : [];
41
+ const groups = [];
42
+ /** tag → how many groups declared it. */
43
+ const tagCounts = new Map();
44
+ /** displayName → how many groups use it. */
45
+ const nameCounts = new Map();
46
+
47
+ for (const record of records) {
48
+ const groupId = record.groupId;
49
+ if (typeof groupId !== 'string' || groupId === '') continue;
50
+
51
+ // What this instance said about itself in THAT group. For a hosted group the
52
+ // store is authoritative; for a joined one only the ledger knows, and it
53
+ // carries no role detail, so `about` stays empty rather than being invented.
54
+ const own = ownMembership(store, groupId, record.personId);
55
+ groups.push({
56
+ groupId,
57
+ groupName: own?.groupName ?? record.groupName ?? groupId,
58
+ roleAbout: own?.roleAbout ?? '',
59
+ tags: own?.tags ?? [],
60
+ // The permission bit, carried for completeness: a peer may care whether it
61
+ // is talking to a group owner, and that is a different fact from the role.
62
+ permission: own?.role ?? record.role ?? 'member',
63
+ local: record.local === true,
64
+ });
65
+
66
+ for (const tag of own?.tags ?? []) {
67
+ tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
68
+ }
69
+ const display = own?.displayName;
70
+ if (typeof display === 'string' && display !== '') {
71
+ nameCounts.set(display, (nameCounts.get(display) ?? 0) + 1);
72
+ }
73
+ }
74
+
75
+ // Most-used tag first; ties broken by the tag itself for a stable order, so two
76
+ // reads of an unchanged instance produce byte-identical cards.
77
+ const tags = [...tagCounts.entries()]
78
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
79
+ .map(([tag]) => tag);
80
+
81
+ const name = pickName({ config, nameCounts, groups });
82
+ const headline = pickHeadline({ config, tags, groups });
83
+
84
+ return { name, headline, groups, tags };
85
+ }
86
+
87
+ /**
88
+ * Placeholder display names that should never become an identity.
89
+ *
90
+ * These are the values a *tool* fills in when a human did not name themselves:
91
+ * `owner` and `member` are the store's own defaults, `群主`/`成员` are its Chinese
92
+ * renderings, and `member-3` is the shape the card client generates. Letting one
93
+ * of them win the "most common name" vote would put "owner" on the agent card,
94
+ * which reads as a bug to every peer that sees it.
95
+ */
96
+ const PLACEHOLDER_NAMES = new Set([
97
+ 'owner', 'member', 'me', 'self', 'user',
98
+ '群主', '成员', '我', '未知',
99
+ ]);
100
+
101
+ /** Whether a display name carries no real identity. */
102
+ function isPlaceholder(name) {
103
+ if (typeof name !== 'string') return true;
104
+ const trimmed = name.trim();
105
+ if (trimmed === '') return true;
106
+ if (PLACEHOLDER_NAMES.has(trimmed.toLowerCase())) return true;
107
+ // `member-1`, `member-12`: generated when a caller supplied no name at all.
108
+ return /^member-\d+$/.test(trimmed);
109
+ }
110
+
111
+ /**
112
+ * What this instance calls itself.
113
+ *
114
+ * An explicit `config.agent.name` always wins — an operator may want a stable
115
+ * identity that does not shift when they join another group. Otherwise the most
116
+ * common REAL display name across memberships is used. Placeholder names are
117
+ * skipped: a card that says "owner" tells a peer nothing and looks broken.
118
+ *
119
+ * @returns {string|undefined} the name, or undefined when nothing real is known.
120
+ */
121
+ function pickName({ config, nameCounts, groups }) {
122
+ const configured = typeof config?.agent?.name === 'string' ? config.agent.name.trim() : '';
123
+ if (configured !== '') return configured;
124
+ const ranked = [...nameCounts.entries()]
125
+ .filter(([name]) => !isPlaceholder(name))
126
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
127
+ if (ranked.length > 0) return ranked[0][0];
128
+ return groups.length > 0 ? undefined : undefined;
129
+ }
130
+
131
+ /**
132
+ * One line describing what this instance does, for the card's `description`.
133
+ *
134
+ * Three cases, in order of how much is known:
135
+ * 1. an explicit `config.agent.description` — the operator's own words;
136
+ * 2. tags exist — name the busiest ones ("主要角色:产品、研发");
137
+ * 3. nothing declared yet — say plainly that this is a DSH instance, rather
138
+ * than emitting an empty string a peer would render as a blank field.
139
+ */
140
+ function pickHeadline({ config, tags, groups }) {
141
+ const configured = typeof config?.agent?.description === 'string' ? config.agent.description.trim() : '';
142
+ if (configured !== '') return configured;
143
+ if (tags.length > 0) {
144
+ const named = tags.slice(0, HEADLINE_LIMIT).join('、');
145
+ const extra = tags.length > HEADLINE_LIMIT ? ` 等 ${tags.length} 类` : '';
146
+ return `在 ${groups.length} 个协作群里,主要角色:${named}${extra}`;
147
+ }
148
+ if (groups.length > 0) {
149
+ return `在 ${groups.length} 个协作群里,尚未声明角色`;
150
+ }
151
+ return undefined;
152
+ }
153
+
154
+ /**
155
+ * This instance's own membership in one group, from the group store when hosted.
156
+ *
157
+ * Returns `undefined` for a joined group: the ledger records that the membership
158
+ * exists, not what was declared there. Reading the host's member list to fill that
159
+ * in would mean a network call inside card rendering — the opposite of what the
160
+ * card is for.
161
+ */
162
+ function ownMembership(store, groupId, personId) {
163
+ if (typeof store?.has !== 'function' || store.has(groupId) !== true) return undefined;
164
+ if (typeof personId !== 'string' || personId === '') return undefined;
165
+ try {
166
+ // `snapshot` is anonymous by design; the self-view needs the credential, which
167
+ // this module deliberately does not hold. `memberView` (added for exactly this)
168
+ // answers "what does the store know about member X" without one.
169
+ if (typeof store.memberView === 'function') return store.memberView({ groupId, personId });
170
+ return undefined;
171
+ } catch {
172
+ // A store that cannot answer must not break the card; the group just shows no
173
+ // declared role.
174
+ return undefined;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * The card's extension field carrying the per-group profile.
180
+ *
181
+ * Prefix `x-` follows the JSON Schema convention for vendor extensions, and A2A
182
+ * gateways (AISIX included) preserve unknown card fields verbatim, so a peer that
183
+ * does not understand this simply ignores it.
184
+ *
185
+ * @param {object} profile - the value from {@link buildProfile}.
186
+ * @returns {object|undefined} the field, or undefined when there is nothing to say.
187
+ */
188
+ export function profileField(profile) {
189
+ if (profile === undefined || profile === null) return undefined;
190
+ if (!Array.isArray(profile.groups) || profile.groups.length === 0) return undefined;
191
+ const entries = { groups: profile.groups };
192
+ if (typeof profile.headline === 'string' && profile.headline !== '') entries.headline = profile.headline;
193
+ if (Array.isArray(profile.tags) && profile.tags.length > 0) entries.tags = profile.tags;
194
+ return entries;
195
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The prompt section: how a Session should behave when it is wired to A2A.
3
+ *
4
+ * This text is the only thing that teaches the model *when* to reach for the
5
+ * A2A tools rather than the local bus, and it is deliberately short. A long
6
+ * section costs tokens on every turn, and the model has the tool descriptions
7
+ * one line away; what it cannot infer from a schema is the etiquette (do not
8
+ * spam a group, close a conversation, ask before broadcasting).
9
+ *
10
+ * @module dsh-a2a/prompt
11
+ */
12
+
13
+ /** Registry name of this section. */
14
+ export const PROMPT_SECTION_NAME = 'a2a';
15
+
16
+ /**
17
+ * Order among other prompt sections.
18
+ *
19
+ * Placed after the bus section: a Session that has both should read "prefer the
20
+ * bus for work on this machine, A2A for other people and other machines", which
21
+ * only reads that way if the bus rules come first.
22
+ */
23
+ export const PROMPT_SECTION_ORDER = 620;
24
+
25
+ /** The section body. Interpolation is off; this is literal text. */
26
+ export const PROMPT_SECTION = `## A2A (other people, other machines)
27
+
28
+ \`dsh-session-bus\` reaches Sessions **on this machine**. A2A reaches agents
29
+ **behind a gateway** — a colleague's Session, a group, or a third-party agent.
30
+ Pick the bus when the work is local; pick A2A when the work belongs to someone
31
+ else.
32
+
33
+ **Before sending anywhere**: \`a2a_discover\` the peer and read its skills. Do not
34
+ guess a skill name or a payload shape — a wrong skill is a rejected call, not a
35
+ retryable one.
36
+
37
+ **Groups are conversation, not broadcast.** A group message wakes every member's
38
+ Session, so it costs everyone a turn. Post when you have something a member
39
+ needs to act on, and answer a group message in the group — not by direct message
40
+ to the person who asked, which leaves the rest of the group blind.
41
+
42
+ **Close what you open.** When a thread is answered, say so and stop; do not
43
+ send an acknowledgement for every message. If you are told the hop budget is
44
+ exhausted, that means two agents are bouncing: send a conclusion, not another
45
+ question.
46
+
47
+ **Never invent a recipient.** If \`a2a_agents\` does not list a peer and the user
48
+ did not give you a link or an address, ask for one. Sending a task into an
49
+ unknown agent is worse than asking which agent was meant.
50
+
51
+ **Group credentials stay out of prose.** An invite link carries a one-time token;
52
+ a group has a password. Do not paste either into a group feed, a Session title,
53
+ or a file that gets committed. If a user asks you to forward an invite, give them
54
+ the link and let them decide where it goes.
55
+ `;
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Session resolution and turn observation for the A2A server half.
3
+ *
4
+ * Two jobs, both about answering "which Session is this agent, and when has it
5
+ * finished thinking":
6
+ *
7
+ * 1. **Binding.** An A2A agent is one DSH instance standing behind one
8
+ * Session. The first `session/ask` that arrives can bind it (a Session that
9
+ * asks its own instance to become an agent is the ordinary case), or the
10
+ * operator names one in config. Both are supported; an unbound agent
11
+ * refuses the call instead of silently picking a Session.
12
+ *
13
+ * 2. **Turn watching.** `session/ask` has to wait for the *turn* it caused,
14
+ * not merely for the delivery call to return — delivery only queues the
15
+ * message. The end of a turn is observable on the Host's Session event
16
+ * stream, so this module subscribes before delivering and resolves when the
17
+ * matching `turn/end` arrives.
18
+ *
19
+ * No `@deepseek-ai/*` import: everything Host-specific arrives through `ctx`.
20
+ *
21
+ * @module dsh-a2a/sessions
22
+ */
23
+
24
+ /** Event names DSH uses for a Session's turn lifecycle, in either vocabulary. */
25
+ const TURN_END_EVENTS = new Set(['turn/end', 'turn-end', 'turnEnd']);
26
+
27
+ /** Event names that carry what the model said. */
28
+ const ASSISTANT_TEXT_EVENTS = new Set(['assistant/message', 'assistant-message', 'assistantMessage']);
29
+
30
+ /**
31
+ * Build the session facade the server and skills share.
32
+ *
33
+ * @param {object} options - construction options.
34
+ * @param {object} options.ctx - plugin context carrying `sessionController`, `sessions`.
35
+ * @param {object} options.config - normalized config.
36
+ * @param {object} [options.logger] - host logger.
37
+ * @param {Function} [options.clock] - epoch-millisecond clock.
38
+ * @returns {object} the facade.
39
+ */
40
+ export function createSessions({ ctx, config, logger, clock = () => Date.now() }) {
41
+ /** Set once something binds this agent to a Session. */
42
+ let boundSessionId;
43
+ /** Sessions already offered by a caller, most recent last. */
44
+ const seen = new Set();
45
+
46
+ const controller = () => {
47
+ const service = ctx?.get?.('sessionController') ?? ctx?.sessionController;
48
+ if (service === undefined) throw new Error('dsh-a2a: the sessionController service is unavailable in this Host');
49
+ return service;
50
+ };
51
+
52
+ /**
53
+ * The Session this agent stands for.
54
+ *
55
+ * Config wins when it names one; `'self'` means "the first Session that talks
56
+ * to me". A configured id is resolved lazily rather than at mount time,
57
+ * because a Host can mount a plugin before any Session exists.
58
+ */
59
+ const resolveBoundSessionId = () => {
60
+ if (typeof boundSessionId === 'string' && boundSessionId !== '') return boundSessionId;
61
+ const configured = config?.server?.bindSessionId ?? 'self';
62
+ if (configured !== 'self') return configured;
63
+ return undefined;
64
+ };
65
+
66
+ /**
67
+ * Offer a Session the chance to bind this agent.
68
+ *
69
+ * Called from the tool surface, where the calling Session is known. It only
70
+ * binds while unbound and while config says `self`, so an operator who pinned
71
+ * a Session cannot have it silently replaced by whoever calls next.
72
+ *
73
+ * @param {string} sessionId - the calling Session.
74
+ * @returns {boolean} true when this call bound it.
75
+ */
76
+ const offerBinding = (sessionId) => {
77
+ if (typeof sessionId !== 'string' || sessionId === '') return false;
78
+ seen.add(sessionId);
79
+ if ((config?.server?.bindSessionId ?? 'self') !== 'self') return false;
80
+ if (typeof boundSessionId === 'string' && boundSessionId !== '') return false;
81
+ boundSessionId = sessionId;
82
+ logger?.info?.(`a2a: bound this agent to Session "${sessionId}" (first caller)`);
83
+ return true;
84
+ };
85
+
86
+ /**
87
+ * Whether the underlying Agent is currently running a turn.
88
+ *
89
+ * Read from the live Agent rather than from the store, so a Session that just
90
+ * went idle is not reported busy for another polling interval.
91
+ */
92
+ const isRunning = (sessionId) => {
93
+ const agent = liveAgent(sessionId);
94
+ if (agent === undefined) return false;
95
+ if (typeof agent.running === 'boolean') return agent.running;
96
+ const state = agent.state;
97
+ return state === 'running' || state === 'busy';
98
+ };
99
+
100
+ const liveAgent = (sessionId) => {
101
+ const agents = ctx?.get?.('agents') ?? ctx?.agents;
102
+ if (agents !== undefined && typeof agents.get === 'function') {
103
+ const agent = agents.get(sessionId);
104
+ if (agent !== undefined && agent !== null) return agent;
105
+ }
106
+ const store = ctx?.get?.('sessions') ?? ctx?.sessions;
107
+ if (store !== undefined && typeof store.get === 'function') {
108
+ return store.get(sessionId);
109
+ }
110
+ return undefined;
111
+ };
112
+
113
+ /**
114
+ * Watch one Session for the end of the next turn, collecting what it says.
115
+ *
116
+ * Subscribing happens before delivery on purpose: delivery can start and even
117
+ * finish a very fast turn, and a listener attached afterwards would wait
118
+ * forever for an event that already happened.
119
+ *
120
+ * The returned `done` promise never rejects on timeout — it resolves with
121
+ * `{timedOut:true}`, because a timeout is an outcome the skill reports, not an
122
+ * exception the transport has to translate.
123
+ *
124
+ * @param {string} sessionId - Session to watch.
125
+ * @param {object} [options] - `{timeoutMs, signal}`.
126
+ * @returns {{done: Promise<{text: string, events: number, timedOut: boolean}>, cancel: Function}} the watcher.
127
+ */
128
+ const watchTurn = (sessionId, options = {}) => {
129
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : config?.server?.taskTimeoutMs ?? 600000;
130
+ /** Latest assistant text seen after the turn started. */
131
+ let text = '';
132
+ let events = 0;
133
+ let settled = false;
134
+ let unsubscribe;
135
+ let timer;
136
+
137
+ const finish = (timedOut) => {
138
+ if (settled) return;
139
+ settled = true;
140
+ if (unsubscribe !== undefined) unsubscribe();
141
+ if (timer !== undefined) clearTimeout(timer);
142
+ resolveOutcome({ text, events, timedOut });
143
+ };
144
+
145
+ /** @type {(value: {text: string, events: number, timedOut: boolean}) => void} */
146
+ let resolveOutcome = () => {};
147
+ const done = new Promise((resolve) => {
148
+ resolveOutcome = resolve;
149
+ });
150
+
151
+ const onEvent = (session, event) => {
152
+ const id = typeof session === 'string' ? session : session?.id;
153
+ if (id !== sessionId) return;
154
+ events += 1;
155
+ const type = event?.type ?? event?.kind;
156
+ if (typeof type === 'string' && ASSISTANT_TEXT_EVENTS.has(type)) {
157
+ const candidate = textOfEvent(event);
158
+ if (candidate !== '') text = candidate;
159
+ return;
160
+ }
161
+ if (typeof type === 'string' && TURN_END_EVENTS.has(type)) {
162
+ const candidate = textOfEvent(event);
163
+ if (candidate !== '') text = candidate;
164
+ finish(false);
165
+ }
166
+ };
167
+
168
+ unsubscribe = attachEventListener(ctx, onEvent);
169
+ timer = setTimeout(() => finish(true), timeoutMs);
170
+ if (typeof timer?.unref === 'function') timer.unref();
171
+ if (options.signal !== undefined) {
172
+ const abort = () => finish(true);
173
+ if (options.signal.aborted) abort();
174
+ else options.signal.addEventListener('abort', abort, { once: true });
175
+ }
176
+
177
+ return {
178
+ done,
179
+ cancel: () => finish(true),
180
+ };
181
+ };
182
+
183
+ /**
184
+ * Read the tail of a Session transcript.
185
+ *
186
+ * Used by `group/*` skills and the UI to answer "what did this Session say"
187
+ * without the caller needing the Host's inspection API.
188
+ *
189
+ * @param {string} sessionId - Session to read.
190
+ * @param {number} [limit] - how many messages to return, newest last.
191
+ * @returns {Promise<Array<{role: string, text: string, at: number}>>} the tail.
192
+ */
193
+ const readTail = async (sessionId, limit = 12) => {
194
+ try {
195
+ const inspection = await controller().inspect?.(sessionId);
196
+ const events = Array.isArray(inspection?.events) ? inspection.events : [];
197
+ const messages = [];
198
+ for (const event of events.slice(-2000)) {
199
+ const type = event?.type ?? event?.kind;
200
+ if (type !== 'user/message' && type !== 'assistant/message') continue;
201
+ const text = textOfEvent(event);
202
+ if (text === '') continue;
203
+ messages.push({ role: type.startsWith('user') ? 'user' : 'assistant', text, at: event.at ?? 0 });
204
+ }
205
+ return messages.slice(-Math.max(1, limit));
206
+ } catch (error) {
207
+ logger?.warn?.(`a2a: could not read the tail of "${sessionId}": ${describe(error)}`);
208
+ return [];
209
+ }
210
+ };
211
+
212
+ return {
213
+ resolveBoundSessionId,
214
+ offerBinding,
215
+ isRunning,
216
+ watchTurn,
217
+ readTail,
218
+ /** For `ping` and the card: what this agent is currently bound to. */
219
+ info: () => ({ boundSessionId: resolveBoundSessionId() ?? null, candidates: [...seen], now: clock() }),
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Subscribe to Session events however this Host exposes them.
225
+ *
226
+ * DSH emits `session/event(session, event)`; older/other compositions expose a
227
+ * per-session subscription. Both are attempted and the first that exists wins,
228
+ * so one helper covers a range of Host versions instead of failing shut.
229
+ *
230
+ * @param {object} ctx - plugin context.
231
+ * @param {Function} listener - `(session, event) => void`.
232
+ * @returns {Function} disposer.
233
+ */
234
+ function attachEventListener(ctx, listener) {
235
+ if (typeof ctx?.on === 'function') {
236
+ const dispose = ctx.on('session/event', listener);
237
+ return typeof dispose === 'function' ? dispose : () => {};
238
+ }
239
+ const events = ctx?.events;
240
+ if (events !== undefined && typeof events.on === 'function') {
241
+ const dispose = events.on('session/event', listener);
242
+ return typeof dispose === 'function' ? dispose : () => {};
243
+ }
244
+ return () => {};
245
+ }
246
+
247
+ /**
248
+ * The text an event carries, in whatever shape the Host used.
249
+ *
250
+ * Kept deliberately tolerant: an event's payload moved between DSH releases
251
+ * (`data`, `message`, `content`, blocks of `{type:'text'}`). Reading all of them
252
+ * means one helper survives an upgrade, where pinning one shape would silently
253
+ * return empty strings.
254
+ */
255
+ function textOfEvent(event) {
256
+ const candidates = [event?.text, event?.data?.text, event?.message?.text, event?.data?.message?.text];
257
+ for (const candidate of candidates) {
258
+ if (typeof candidate === 'string' && candidate.trim() !== '') return candidate;
259
+ }
260
+ for (const block of contentBlocks(event)) {
261
+ if (typeof block?.text === 'string' && block.text.trim() !== '') return block.text;
262
+ }
263
+ return '';
264
+ }
265
+
266
+ /** Every plausible content-block array in an event. */
267
+ function contentBlocks(event) {
268
+ const candidates = [event?.content, event?.parts, event?.data?.content, event?.message?.content, event?.data?.message?.content];
269
+ for (const candidate of candidates) {
270
+ if (Array.isArray(candidate)) return candidate;
271
+ }
272
+ return [];
273
+ }
274
+
275
+ /** One line about an error, without a stack. */
276
+ function describe(error) {
277
+ return error instanceof Error ? error.message : String(error);
278
+ }