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
|
@@ -0,0 +1,1349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every tool's execution, host-agnostic.
|
|
3
|
+
*
|
|
4
|
+
* The DSH adapter (`tools.js`) and the MCP server (`mcp-server.js`) both call
|
|
5
|
+
* `call(name, args, caller)`. What differs between them is only how they reach this
|
|
6
|
+
* function:
|
|
7
|
+
*
|
|
8
|
+
* DSH → exec.agent.id → caller.sessionId
|
|
9
|
+
* MCP → the connection's identity → caller.sessionId
|
|
10
|
+
*
|
|
11
|
+
* That single mapping is why the logic lives here rather than in either adapter. Two
|
|
12
|
+
* copies of nineteen tool bodies would drift, and the drift would show up as a tool that
|
|
13
|
+
* behaves differently depending on which client called it.
|
|
14
|
+
*
|
|
15
|
+
* Dependencies arrive as parameters rather than as closures over a plugin context: the
|
|
16
|
+
* same reason. A host that is not DSH has no `ctx` to close over.
|
|
17
|
+
*
|
|
18
|
+
* @module dsh-a2a/tool-runtime
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { parseInviteLink } from '../dsh/tools.js';
|
|
22
|
+
import { probeReachability } from '../core/probe.js';
|
|
23
|
+
// The member's own address, composed from the MEMBER's config — this file runs on the member's
|
|
24
|
+
// machine when it joins, which is the only process that knows this.
|
|
25
|
+
import { ownEndpointFromConfig } from '../core/endpoint.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build the runtime over one set of dependencies.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} deps - `{ config, registry, bridge, store, identities, server, sessions, logger, inbox }`.
|
|
31
|
+
* @returns {{call: (name: string, args: object, caller?: object) => Promise<object>, has: (name: string) => boolean, names: () => string[]}} the runtime.
|
|
32
|
+
*/
|
|
33
|
+
export function createToolRuntime(deps = {}) {
|
|
34
|
+
const { config, registry, bridge, store, identities, server, sessions, logger, inbox } = deps;
|
|
35
|
+
|
|
36
|
+
/** A non-empty string argument, or a coded error. */
|
|
37
|
+
function requireText(value, name) {
|
|
38
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
39
|
+
throw coded('invalid_arguments', `"${name}" must be a non-empty string`);
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Parse an optional JSON-object argument supplied as a string. */
|
|
45
|
+
function parseJsonObject(value) {
|
|
46
|
+
if (value === undefined) return undefined;
|
|
47
|
+
if (typeof value !== 'string') return value;
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(value);
|
|
50
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : undefined;
|
|
51
|
+
} catch {
|
|
52
|
+
throw coded('invalid_arguments', '"data" must be a JSON object');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** An error carrying a stable code. */
|
|
57
|
+
function coded(code, message) {
|
|
58
|
+
const error = new Error(`[${code}] ${message}`);
|
|
59
|
+
error.code = code;
|
|
60
|
+
return error;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Shorten a string for a one-line summary. */
|
|
64
|
+
function truncate(text, limit) {
|
|
65
|
+
const single = String(text).replace(/\s+/g, ' ').trim();
|
|
66
|
+
return single.length <= limit ? single : `${single.slice(0, limit - 1)}…`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Join a gateway base and an agent name. */
|
|
70
|
+
function joinUrl(base, agent) {
|
|
71
|
+
return `${String(base).replace(/\/+$/, '')}/${String(agent).replace(/^\/+/, '')}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve a tool's `agent` argument to a bridge target.
|
|
76
|
+
*
|
|
77
|
+
* A raw URL and a registered name are both accepted because a user who pastes a
|
|
78
|
+
* link should not have to register it first.
|
|
79
|
+
*/
|
|
80
|
+
function resolveTarget(registry, config, reference) {
|
|
81
|
+
const text = requireText(reference, 'agent');
|
|
82
|
+
if (/^https?:\/\//i.test(text)) {
|
|
83
|
+
return {
|
|
84
|
+
baseUrl: text,
|
|
85
|
+
apiKey: config.client.apiKey,
|
|
86
|
+
forwardProto: config.client.forwardProto,
|
|
87
|
+
protocolVersion: config.client.protocolVersion,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const entry = registry.get(text);
|
|
91
|
+
if (entry === undefined) {
|
|
92
|
+
throw coded('unknown_agent', `"${text}" is not a registered A2A agent; run a2a_agents to list them, or pass a full URL`);
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
baseUrl: entry.baseUrl,
|
|
96
|
+
apiKey: entry.apiKey ?? config.client.apiKey,
|
|
97
|
+
forwardProto: config.client.forwardProto,
|
|
98
|
+
protocolVersion: entry.protocolVersion ?? config.client.protocolVersion,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The bridge target for a joined (remote) membership. */
|
|
103
|
+
/**
|
|
104
|
+
* The full address of a group host, from the two facts an invite link carries.
|
|
105
|
+
*
|
|
106
|
+
* `gw` is the gateway root and `agent` is the entry under it; the callable address is both. Keeping only
|
|
107
|
+
* `gw` produces a path the gateway answers 404 for, because routing happens on `/a2a/<agent>`.
|
|
108
|
+
*
|
|
109
|
+
* An absent agent is left alone rather than guessed: a link that names no agent may be pointing straight
|
|
110
|
+
* at a host (local development), where `gw` alone IS the address.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} [base] the gateway base, e.g. `http://gw:3000/a2a`.
|
|
113
|
+
* @param {string} [agent] the agent name, e.g. `agent-group-host`.
|
|
114
|
+
* @returns {string|undefined} the address to call.
|
|
115
|
+
*/
|
|
116
|
+
function composeMemberBase(base, agent) {
|
|
117
|
+
const trimmed = typeof base === 'string' ? base.replace(/\/+$/u, '') : '';
|
|
118
|
+
if (trimmed === '') return undefined;
|
|
119
|
+
const name = typeof agent === 'string' ? agent.trim() : '';
|
|
120
|
+
if (name === '') return trimmed;
|
|
121
|
+
// Already carries its agent (a hand-written `url` link, or a re-join of an older record).
|
|
122
|
+
if (trimmed.endsWith('/' + name)) return trimmed;
|
|
123
|
+
return trimmed + '/' + name;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function targetOf(membership, config) {
|
|
127
|
+
return {
|
|
128
|
+
baseUrl: membership.baseUrl ?? config.groups.gatewayBase,
|
|
129
|
+
apiKey: config.client.apiKey,
|
|
130
|
+
forwardProto: config.client.forwardProto,
|
|
131
|
+
protocolVersion: config.client.protocolVersion,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The host a group lives on, for the paths that have no membership record yet.
|
|
137
|
+
*
|
|
138
|
+
* `a2a_group_request` runs BEFORE the applicant is a member, so there is no
|
|
139
|
+
* `memberships.json` row to read a `baseUrl` from. The address therefore comes from
|
|
140
|
+
* the caller (a registered agent name or a URL) or, failing that, from the
|
|
141
|
+
* configured group host. This is the one place the plugin has to guess, and it
|
|
142
|
+
* guesses only where the config says the host is.
|
|
143
|
+
*/
|
|
144
|
+
function hostTarget(registry, config, reference) {
|
|
145
|
+
if (typeof reference === 'string' && reference !== '') {
|
|
146
|
+
return resolveTarget(registry, config, reference);
|
|
147
|
+
}
|
|
148
|
+
if (config.groups.gatewayBase === '' || config.groups.agentName === '') {
|
|
149
|
+
throw coded(
|
|
150
|
+
'missing_endpoint',
|
|
151
|
+
'this tool needs to know which host to talk to: pass `agent`, or configure groups.gatewayBase + groups.agentName.',
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
baseUrl: joinUrl(config.groups.gatewayBase, config.groups.agentName),
|
|
156
|
+
apiKey: config.client.apiKey,
|
|
157
|
+
forwardProto: config.client.forwardProto,
|
|
158
|
+
protocolVersion: config.client.protocolVersion,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The gateway entry this instance needs, as YAML an operator can paste. */
|
|
163
|
+
function gatewayEntryFor(info, config) {
|
|
164
|
+
return [
|
|
165
|
+
'a2a_agents:',
|
|
166
|
+
` - name: "${config.groups.agentName || 'my-dsh'}"`,
|
|
167
|
+
` url: "${info.url}"`,
|
|
168
|
+
` protocol_version: "${info.protocolVersion}"`,
|
|
169
|
+
' auth_type: api_key',
|
|
170
|
+
` secret: "${info.tokenConfigured ? '<server.token from this profile>' : '<set server.token first, or this only works from loopback>'}"`,
|
|
171
|
+
' timeout_ms: 600000',
|
|
172
|
+
' enabled: true',
|
|
173
|
+
].join('\n');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A local membership record for THIS Session, or a coded error.
|
|
178
|
+
*
|
|
179
|
+
* The Session is required: two Sessions of one instance may both be members of the
|
|
180
|
+
* same group, and picking "the first record" would administer somebody else's
|
|
181
|
+
* membership.
|
|
182
|
+
*/
|
|
183
|
+
function requireLocalMembership(identities, groupId, sessionId) {
|
|
184
|
+
const record = identities.get(groupId, sessionId);
|
|
185
|
+
if (record === undefined) {
|
|
186
|
+
throw coded('unknown_group', `this Session has not joined "${groupId}"; run a2a_groups to list the groups it belongs to`);
|
|
187
|
+
}
|
|
188
|
+
if (record.local !== true) {
|
|
189
|
+
throw coded('not_hosted_here', `"${groupId}" is hosted elsewhere; only this instance's own groups can be administered here`);
|
|
190
|
+
}
|
|
191
|
+
return record;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A membership record for any group, hosted here or joined elsewhere, for THIS Session. */
|
|
195
|
+
function requireMembership(store, identities, groupId, sessionId) {
|
|
196
|
+
const record = identities.get(groupId, sessionId);
|
|
197
|
+
if (record !== undefined) return record;
|
|
198
|
+
// A group hosted by this instance but not in the ledger should not happen (the
|
|
199
|
+
// ledger is written when the group is created), and guessing a credential here
|
|
200
|
+
// would be worse than saying so.
|
|
201
|
+
if (store.has?.(groupId) === true) {
|
|
202
|
+
throw coded('missing_identity', `"${groupId}" is hosted here but this Session has no stored credential for it; join it again`);
|
|
203
|
+
}
|
|
204
|
+
throw coded('unknown_group', `"${groupId}" is not one of this Session's groups; run a2a_groups to list them`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Hosted-group summaries as a map, tolerant of a store that cannot list. */
|
|
208
|
+
function safeList(store, identities) {
|
|
209
|
+
const map = new Map();
|
|
210
|
+
try {
|
|
211
|
+
for (const group of store.list() ?? []) map.set(group.groupId, group);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
loggerless(error);
|
|
214
|
+
}
|
|
215
|
+
void identities;
|
|
216
|
+
return map;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** A one-line summary of the last message in a group summary. */
|
|
220
|
+
function lastMessageOf(summary, record) {
|
|
221
|
+
const entry = summary?.feedTail?.at?.(-1);
|
|
222
|
+
if (entry !== undefined && typeof entry.text === 'string') {
|
|
223
|
+
return `${entry.from?.displayName ?? '?'}: ${truncate(entry.text, 120)}`;
|
|
224
|
+
}
|
|
225
|
+
void record;
|
|
226
|
+
return '';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The registry as the flat list the tool returns. */
|
|
230
|
+
function listEntries(registry) {
|
|
231
|
+
return registry.list().map((entry) => ({
|
|
232
|
+
name: entry.name,
|
|
233
|
+
baseUrl: entry.baseUrl,
|
|
234
|
+
enabled: entry.enabled !== false,
|
|
235
|
+
note: entry.note ?? '',
|
|
236
|
+
}));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Swallow a diagnostic-only failure; the caller already has a fallback. */
|
|
240
|
+
function loggerless() {}
|
|
241
|
+
|
|
242
|
+
/** A one-line summary of the last message in a group summary. */
|
|
243
|
+
function lastMessageOf(summary, record) {
|
|
244
|
+
const entry = summary?.feedTail?.at?.(-1);
|
|
245
|
+
if (entry !== undefined && typeof entry.text === 'string') {
|
|
246
|
+
return `${entry.from?.displayName ?? '?'}: ${truncate(entry.text, 120)}`;
|
|
247
|
+
}
|
|
248
|
+
void record;
|
|
249
|
+
return '';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The display name to record for the caller when they did not supply one.
|
|
255
|
+
*
|
|
256
|
+
* A display name is shown to every member in the group feed, so it has to read as a person.
|
|
257
|
+
* A Session id does not: `session-1f3a9c` in a member list looks like a bug, and it also
|
|
258
|
+
* leaks an internal identifier to everybody in the group.
|
|
259
|
+
*
|
|
260
|
+
* Order: an explicit name the adapter knows, then the store default. Returning `undefined`
|
|
261
|
+
* rather than throwing is deliberate — creating a group must not fail because nobody has
|
|
262
|
+
* named themselves yet, and the owner can rename themselves afterwards.
|
|
263
|
+
*
|
|
264
|
+
* @param {object} exec - the tool execution context from the host adapter.
|
|
265
|
+
* @returns {string|undefined} a name, or undefined to let the store default apply.
|
|
266
|
+
*/
|
|
267
|
+
function displayNameOf(exec) {
|
|
268
|
+
if (exec === null || typeof exec !== 'object') return undefined;
|
|
269
|
+
// Adapters differ in where they expose the signed-in person; take the first usable one.
|
|
270
|
+
const candidates = [
|
|
271
|
+
exec.displayName,
|
|
272
|
+
exec.agent?.displayName,
|
|
273
|
+
exec.agent?.name,
|
|
274
|
+
exec.user?.displayName,
|
|
275
|
+
exec.user?.name,
|
|
276
|
+
];
|
|
277
|
+
for (const candidate of candidates) {
|
|
278
|
+
if (typeof candidate === 'string' && candidate.trim() !== '') return candidate.trim();
|
|
279
|
+
}
|
|
280
|
+
// Undefined means "use the store default", keeping that decision in one place.
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The gateway agent name this host is registered under, if configured.
|
|
286
|
+
*
|
|
287
|
+
* Used only to word the "next steps" hint after creating a group, whose point is that a new
|
|
288
|
+
* group needs no new gateway entry. An unconfigured host gets `undefined`, so the caller words
|
|
289
|
+
* the hint without a name instead of printing "undefined".
|
|
290
|
+
*
|
|
291
|
+
* @param {object} config - normalized config.
|
|
292
|
+
* @returns {string|undefined} the agent name.
|
|
293
|
+
*/
|
|
294
|
+
function settingsAgentName(config) {
|
|
295
|
+
const name = config?.groups?.agentName;
|
|
296
|
+
return typeof name === 'string' && name.trim() !== '' ? name.trim() : undefined;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The caller behind a tool invocation.
|
|
301
|
+
*
|
|
302
|
+
* Hosts differ in how they name the caller: DSH puts a Session id at `exec.agent.id`,
|
|
303
|
+
* while an MCP connection has an id of its own and no Session at all. Both are reduced to
|
|
304
|
+
* `{ id }` here, so every tool body can stay host-agnostic.
|
|
305
|
+
*
|
|
306
|
+
* A tool that genuinely needs a Session (all the group mutators do, because a membership
|
|
307
|
+
* belongs to one) calls `requireSession`, which explains itself when the host cannot
|
|
308
|
+
* supply one.
|
|
309
|
+
*
|
|
310
|
+
* @param {object} exec - `{ agent?: { id }, sessionId?: string }`.
|
|
311
|
+
* @returns {{id: string|undefined}} the caller.
|
|
312
|
+
*/
|
|
313
|
+
function senderOf(exec) {
|
|
314
|
+
if (exec === null || typeof exec !== 'object') return { id: undefined };
|
|
315
|
+
if (typeof exec.sessionId === 'string' && exec.sessionId !== '') return { id: exec.sessionId };
|
|
316
|
+
const agent = exec.agent;
|
|
317
|
+
const id = agent !== null && typeof agent === 'object' && typeof agent.id === 'string' ? agent.id : undefined;
|
|
318
|
+
return { id };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* A tool call must name its caller.
|
|
323
|
+
*
|
|
324
|
+
* Every group operation is scoped to one conversation, and a membership belongs to one —
|
|
325
|
+
* so a host that cannot say who is calling cannot be served correctly. Failing loudly
|
|
326
|
+
* beats guessing: a wrong guess would act on somebody else's membership.
|
|
327
|
+
*
|
|
328
|
+
* @param {string|undefined} sessionId - the caller's identity.
|
|
329
|
+
* @param {string} tool - tool name, for the message.
|
|
330
|
+
* @returns {string} the id.
|
|
331
|
+
*/
|
|
332
|
+
function requireSession(sessionId, tool) {
|
|
333
|
+
if (typeof sessionId !== 'string' || sessionId === '') {
|
|
334
|
+
throw coded('no_session', `${tool} needs to know which conversation is calling, and the host did not provide one`);
|
|
335
|
+
}
|
|
336
|
+
return sessionId;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The caller acting through this tool.
|
|
341
|
+
*
|
|
342
|
+
* `sessionId` is what a DSH Session contributes; a host without Sessions passes a
|
|
343
|
+
* stable id of its own (an MCP connection's id, say), which is enough because the group
|
|
344
|
+
* tools only need something consistent to key a membership by.
|
|
345
|
+
*/
|
|
346
|
+
const normaliseCaller = (caller) => {
|
|
347
|
+
if (caller === null || typeof caller !== 'object') return { sessionId: undefined };
|
|
348
|
+
return { sessionId: typeof caller.sessionId === 'string' ? caller.sessionId : undefined };
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
/** Every tool body, keyed by name. */
|
|
352
|
+
const HANDLERS = {
|
|
353
|
+
'a2a_agents': async function a2a_agents(args, exec) {
|
|
354
|
+
const action = args.action ?? 'list';
|
|
355
|
+
if (action === 'add') {
|
|
356
|
+
const name = requireText(args.name, 'name');
|
|
357
|
+
const entry = args.gatewayBase !== undefined && args.agent !== undefined
|
|
358
|
+
? { name, baseUrl: joinUrl(args.gatewayBase, args.agent), note: args.note, apiKey: args.apiKey }
|
|
359
|
+
: { name, baseUrl: requireText(args.baseUrl, 'baseUrl, or gatewayBase together with agent'), note: args.note, apiKey: args.apiKey };
|
|
360
|
+
registry.upsert(entry);
|
|
361
|
+
await registry.flush();
|
|
362
|
+
return { agents: listEntries(registry), changed: `added ${name}` };
|
|
363
|
+
}
|
|
364
|
+
if (action === 'remove') {
|
|
365
|
+
const name = requireText(args.name, 'name');
|
|
366
|
+
const removed = registry.remove(name);
|
|
367
|
+
await registry.flush();
|
|
368
|
+
return { agents: listEntries(registry), changed: removed ? `removed ${name}` : `${name} was not registered` };
|
|
369
|
+
}
|
|
370
|
+
return { agents: listEntries(registry) };
|
|
371
|
+
},
|
|
372
|
+
'a2a_discover': async function a2a_discover(args, exec) {
|
|
373
|
+
const target = resolveTarget(registry, config, args.agent);
|
|
374
|
+
const card = await bridge.discover(target);
|
|
375
|
+
return {
|
|
376
|
+
name: card.name,
|
|
377
|
+
url: card.url,
|
|
378
|
+
protocolVersion: card.protocolVersion ?? config.client.protocolVersion,
|
|
379
|
+
streaming: card.capabilities?.streaming === true,
|
|
380
|
+
skills: (card.skills ?? []).map((skill) => ({
|
|
381
|
+
id: skill.id,
|
|
382
|
+
name: skill.name ?? skill.id,
|
|
383
|
+
description: skill.description ?? '',
|
|
384
|
+
})),
|
|
385
|
+
};
|
|
386
|
+
},
|
|
387
|
+
'a2a_send': async function a2a_send(args, exec) {
|
|
388
|
+
const target = resolveTarget(registry, config, args.agent);
|
|
389
|
+
const result = await bridge.send(target, {
|
|
390
|
+
skill: args.skill ?? 'session/ask',
|
|
391
|
+
text: args.message,
|
|
392
|
+
data: parseJsonObject(args.data),
|
|
393
|
+
timeoutMs: args.timeoutMs ?? config.client.timeoutMs,
|
|
394
|
+
from: senderOf(exec).id,
|
|
395
|
+
});
|
|
396
|
+
return { state: result.state, text: result.text, taskId: result.taskId };
|
|
397
|
+
},
|
|
398
|
+
'a2a_stream': async function a2a_stream(args, exec) {
|
|
399
|
+
const target = resolveTarget(registry, config, args.agent);
|
|
400
|
+
const events = [];
|
|
401
|
+
const result = await bridge.stream(target, {
|
|
402
|
+
skill: args.skill ?? 'session/ask',
|
|
403
|
+
text: args.message,
|
|
404
|
+
data: parseJsonObject(args.data),
|
|
405
|
+
from: senderOf(exec).id,
|
|
406
|
+
onEvent: (event) => {
|
|
407
|
+
if (event?.type === 'artifact' || event?.type === 'status') events.push(event);
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
const printed = events
|
|
411
|
+
.filter((event) => event.type === 'status' && typeof event.note === 'string' && event.note !== '')
|
|
412
|
+
.map((event) => `· ${event.note}`);
|
|
413
|
+
return {
|
|
414
|
+
state: result.state,
|
|
415
|
+
text: printed.length === 0 ? result.text : `${printed.join('\n')}\n\n${result.text}`,
|
|
416
|
+
events: events.length,
|
|
417
|
+
};
|
|
418
|
+
},
|
|
419
|
+
'a2a_person_send': async function a2a_person_send(args, exec) {
|
|
420
|
+
const sessionId = requireSession(senderOf(exec).id, 'a2a_person_send');
|
|
421
|
+
const to = requireText(args.to, 'to');
|
|
422
|
+
const kind = requireText(args.kind, 'kind');
|
|
423
|
+
// The sender is whoever this Session is in the group the message is about. For an invitation that
|
|
424
|
+
// group is required, because the credential comes from it and the link is scoped to it.
|
|
425
|
+
const groupId = typeof args.groupId === 'string' && args.groupId !== ''
|
|
426
|
+
? args.groupId
|
|
427
|
+
: requireText(args.groupId, 'groupId for a group invitation');
|
|
428
|
+
const membership = requireMembership(store, identities, groupId, sessionId);
|
|
429
|
+
if (membership.local === true) {
|
|
430
|
+
const sent = store.sendPersonMessage({
|
|
431
|
+
fromPersonId: membership.personId,
|
|
432
|
+
toPersonId: to,
|
|
433
|
+
kind,
|
|
434
|
+
text: args.text ?? '',
|
|
435
|
+
payload: undefined,
|
|
436
|
+
});
|
|
437
|
+
await store.flush();
|
|
438
|
+
return { dmId: sent.dmId, online: store.isPersonOnline(to), kind };
|
|
439
|
+
}
|
|
440
|
+
const result = await bridge.send(targetOf(membership, config), {
|
|
441
|
+
skill: 'person/send',
|
|
442
|
+
text: `send ${kind} to ${to}`,
|
|
443
|
+
data: {
|
|
444
|
+
groupId,
|
|
445
|
+
personId: membership.personId,
|
|
446
|
+
personKey: membership.personKey,
|
|
447
|
+
fromPersonId: membership.personId,
|
|
448
|
+
toPersonId: to,
|
|
449
|
+
kind,
|
|
450
|
+
text: args.text,
|
|
451
|
+
},
|
|
452
|
+
timeoutMs: config.client.timeoutMs,
|
|
453
|
+
});
|
|
454
|
+
const meta = result.data ?? {};
|
|
455
|
+
return {
|
|
456
|
+
dmId: typeof meta.dmId === 'string' ? meta.dmId : '',
|
|
457
|
+
online: meta.online === true,
|
|
458
|
+
kind,
|
|
459
|
+
};
|
|
460
|
+
},
|
|
461
|
+
|
|
462
|
+
'a2a_person_inbox': async function a2a_person_inbox(args, exec) {
|
|
463
|
+
const sessionId = requireSession(senderOf(exec).id, 'a2a_person_inbox');
|
|
464
|
+
// Any membership works for reaching the host: it is the credential, and the inbox is keyed by
|
|
465
|
+
// person, not by group.
|
|
466
|
+
const membership = identities.list({ sessionId })[0];
|
|
467
|
+
if (membership === undefined) {
|
|
468
|
+
throw coded('not_member', '这个会话还没有加入任何协作群,因此没有收件箱。');
|
|
469
|
+
}
|
|
470
|
+
if (membership.local === true) {
|
|
471
|
+
const messages = store.listPersonMessages({ personId: membership.personId, since: args.since });
|
|
472
|
+
return { messages };
|
|
473
|
+
}
|
|
474
|
+
const result = await bridge.send(targetOf(membership, config), {
|
|
475
|
+
skill: 'person/inbox',
|
|
476
|
+
text: 'inbox',
|
|
477
|
+
data: { personId: membership.personId, since: args.since },
|
|
478
|
+
timeoutMs: config.client.timeoutMs,
|
|
479
|
+
});
|
|
480
|
+
const meta = result.data ?? {};
|
|
481
|
+
return { messages: Array.isArray(meta.messages) ? meta.messages : [] };
|
|
482
|
+
},
|
|
483
|
+
|
|
484
|
+
'a2a_person_ack': async function a2a_person_ack(args, exec) {
|
|
485
|
+
const sessionId = requireSession(senderOf(exec).id, 'a2a_person_ack');
|
|
486
|
+
const dmIds = Array.isArray(args.dmIds) ? args.dmIds.filter((id) => typeof id === 'string') : [];
|
|
487
|
+
if (dmIds.length === 0) throw coded('bad_argument', 'Nothing to confirm: dmIds is empty.');
|
|
488
|
+
const membership = identities.list({ sessionId })[0];
|
|
489
|
+
if (membership === undefined) {
|
|
490
|
+
throw coded('not_member', '这个会话还没有加入任何协作群,因此没有收件箱。');
|
|
491
|
+
}
|
|
492
|
+
if (membership.local === true) {
|
|
493
|
+
const acked = store.ackPersonMessages({ personId: membership.personId, dmIds });
|
|
494
|
+
await store.flush();
|
|
495
|
+
return acked;
|
|
496
|
+
}
|
|
497
|
+
const result = await bridge.send(targetOf(membership, config), {
|
|
498
|
+
skill: 'person/ack',
|
|
499
|
+
text: 'ack',
|
|
500
|
+
data: { personId: membership.personId, dmIds },
|
|
501
|
+
timeoutMs: config.client.timeoutMs,
|
|
502
|
+
});
|
|
503
|
+
const meta = result.data ?? {};
|
|
504
|
+
return {
|
|
505
|
+
acked: Number.isFinite(meta.acked) ? meta.acked : 0,
|
|
506
|
+
unknown: Array.isArray(meta.unknown) ? meta.unknown : [],
|
|
507
|
+
};
|
|
508
|
+
},
|
|
509
|
+
|
|
510
|
+
'a2a_pending': async function a2a_pending(args, exec) {
|
|
511
|
+
const sessionId = requireSession(senderOf(exec).id, 'a2a_pending');
|
|
512
|
+
const includeGroups = args.includeGroups !== false;
|
|
513
|
+
|
|
514
|
+
// Direct messages first: an invitation blocks progress, a group message does not.
|
|
515
|
+
const directMessages = [];
|
|
516
|
+
const memberships = identities.list({ sessionId });
|
|
517
|
+
if (memberships.length > 0) {
|
|
518
|
+
const first = memberships[0];
|
|
519
|
+
try {
|
|
520
|
+
if (first.local === true) {
|
|
521
|
+
for (const message of store.listPersonMessages({ personId: first.personId })) {
|
|
522
|
+
// Only unconfirmed ones count as pending: a message already acked has been delivered and read.
|
|
523
|
+
if (message.deliveredAt !== undefined) continue;
|
|
524
|
+
directMessages.push({ dmId: message.dmId, kind: message.kind, text: message.text });
|
|
525
|
+
}
|
|
526
|
+
} else {
|
|
527
|
+
const result = await bridge.send(targetOf(first, config), {
|
|
528
|
+
skill: 'person/inbox',
|
|
529
|
+
text: 'inbox',
|
|
530
|
+
data: { personId: first.personId },
|
|
531
|
+
timeoutMs: config.client.timeoutMs,
|
|
532
|
+
});
|
|
533
|
+
const list = Array.isArray(result?.data?.messages) ? result.data.messages : [];
|
|
534
|
+
for (const message of list) {
|
|
535
|
+
if (message.deliveredAt !== undefined) continue;
|
|
536
|
+
directMessages.push({ dmId: message.dmId, kind: message.kind, text: message.text });
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
} catch {
|
|
540
|
+
// An unreachable host must not make this probe throw: the whole point is that it is cheap and safe to
|
|
541
|
+
// call often. An empty result is honest — it says nothing about pending count, and the caller can
|
|
542
|
+
// still act on groups.
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Then groups with unread messages. Unread is "the host's head minus what this Session last read", which
|
|
547
|
+
// is already how the panel counts it — computed the same way here so the two agree.
|
|
548
|
+
const groups = [];
|
|
549
|
+
if (includeGroups === true) {
|
|
550
|
+
for (const record of memberships) {
|
|
551
|
+
if (record.local !== true) {
|
|
552
|
+
// A remote group's head is only known after a read; without one there is no honest unread count, and
|
|
553
|
+
// inventing zero would hide new messages. Reported as unknown rather than as zero.
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
let head = 0;
|
|
557
|
+
let name = record.groupName ?? record.groupId;
|
|
558
|
+
try {
|
|
559
|
+
const summary = store.snapshot({ groupId: record.groupId, member: { personId: record.personId, personKey: record.personKey } });
|
|
560
|
+
head = summary.lastSeq ?? 0;
|
|
561
|
+
name = summary.name ?? name;
|
|
562
|
+
} catch {
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const seen = Number.isFinite(record.lastSeq) ? record.lastSeq : 0;
|
|
566
|
+
const unread = Math.max(0, head - seen);
|
|
567
|
+
if (unread > 0) groups.push({ groupId: record.groupId, name, unread });
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
total: directMessages.length + groups.reduce((sum, entry) => sum + entry.unread, 0),
|
|
573
|
+
directMessages,
|
|
574
|
+
groups,
|
|
575
|
+
summary: directMessages.length === 0 && groups.length === 0
|
|
576
|
+
? 'Nothing waiting.'
|
|
577
|
+
: `${directMessages.length} direct message(s), ${groups.length} group(s) with unread.`,
|
|
578
|
+
};
|
|
579
|
+
},
|
|
580
|
+
|
|
581
|
+
'a2a_group_create': async function a2a_group_create(args, exec) {
|
|
582
|
+
const sessionId = requireSession(senderOf(exec).id, 'a2a_group_create');
|
|
583
|
+
const name = requireText(args.name, 'name');
|
|
584
|
+
const topic = args.topic ?? '';
|
|
585
|
+
const visibility = args.visibility === 'unlisted' ? 'unlisted' : 'public';
|
|
586
|
+
|
|
587
|
+
// A local store with groups enabled means this instance is itself a host. That
|
|
588
|
+
// is the rarer setup now, but it stays supported: a single machine can host its
|
|
589
|
+
// own groups with no gateway at all.
|
|
590
|
+
const hostedHere = config.groups.embedded === true;
|
|
591
|
+
const target = hostedHere ? undefined : hostTarget(registry, config, args.agent);
|
|
592
|
+
|
|
593
|
+
let groupId;
|
|
594
|
+
let personId;
|
|
595
|
+
let ownerKey;
|
|
596
|
+
let groupName;
|
|
597
|
+
let remote;
|
|
598
|
+
|
|
599
|
+
if (hostedHere) {
|
|
600
|
+
const created = store.create({
|
|
601
|
+
name,
|
|
602
|
+
topic,
|
|
603
|
+
ownerDisplayName: displayNameOf(exec),
|
|
604
|
+
settings: { visibility },
|
|
605
|
+
});
|
|
606
|
+
groupId = created.group.groupId;
|
|
607
|
+
personId = created.group.owner.personId;
|
|
608
|
+
ownerKey = created.ownerKey;
|
|
609
|
+
groupName = created.group.name;
|
|
610
|
+
remote = false;
|
|
611
|
+
} else {
|
|
612
|
+
const result = await bridge.send(target, {
|
|
613
|
+
skill: 'group/create',
|
|
614
|
+
text: `create a group named ${name}`,
|
|
615
|
+
data: {
|
|
616
|
+
name,
|
|
617
|
+
topic,
|
|
618
|
+
ownerDisplayName: displayNameOf(exec),
|
|
619
|
+
// The skill reads this from the top level, not from a nested settings bag.
|
|
620
|
+
visibility,
|
|
621
|
+
},
|
|
622
|
+
timeoutMs: config.client.timeoutMs,
|
|
623
|
+
});
|
|
624
|
+
const meta = result.data ?? {};
|
|
625
|
+
groupId = meta.groupId;
|
|
626
|
+
personId = meta.personId;
|
|
627
|
+
ownerKey = meta.ownerKey;
|
|
628
|
+
groupName = meta.name ?? name;
|
|
629
|
+
remote = true;
|
|
630
|
+
if (typeof groupId !== 'string' || groupId === '') {
|
|
631
|
+
throw coded('bad_response', 'the group host did not return a groupId; check that the host runs a compatible agent-groups version');
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// The membership record belongs to the Session that created the group: it is the
|
|
636
|
+
// owner, and it is where invite/approve calls will be issued from.
|
|
637
|
+
identities.upsert({
|
|
638
|
+
sessionId,
|
|
639
|
+
groupId,
|
|
640
|
+
personId,
|
|
641
|
+
personKey: ownerKey,
|
|
642
|
+
local: remote === false,
|
|
643
|
+
baseUrl: remote === true ? target.baseUrl : undefined,
|
|
644
|
+
groupName,
|
|
645
|
+
role: 'owner',
|
|
646
|
+
lastSeq: 0,
|
|
647
|
+
});
|
|
648
|
+
await identities.flush();
|
|
649
|
+
if (remote === false) await store.flush();
|
|
650
|
+
|
|
651
|
+
const gatewayName = settingsAgentName(config);
|
|
652
|
+
return {
|
|
653
|
+
groupId,
|
|
654
|
+
name: groupName,
|
|
655
|
+
personId,
|
|
656
|
+
ownerKey,
|
|
657
|
+
agentName: gatewayName,
|
|
658
|
+
remote,
|
|
659
|
+
nextSteps: remote === true
|
|
660
|
+
? 'Next: a2a_group_invite to mint an invite link and a paste-ready message for a colleague.'
|
|
661
|
+
+ `\nThe host serves every group under one gateway entry (${gatewayName ?? 'not configured'}); a new group needs no gateway change.`
|
|
662
|
+
: 'Next: a2a_group_invite to mint an invite link and a paste-ready message for a colleague.'
|
|
663
|
+
+ '\nThis group lives on this instance, so colleagues reach it directly or through a gateway you configure.',
|
|
664
|
+
};
|
|
665
|
+
},
|
|
666
|
+
'a2a_group_invite': async function a2a_group_invite(args, exec) {
|
|
667
|
+
const groupId = requireText(args.groupId, 'groupId');
|
|
668
|
+
const sessionId = requireSession(senderOf(exec).id, 'a2a_group_invite');
|
|
669
|
+
const membership = identities.get(groupId, sessionId);
|
|
670
|
+
if (membership === undefined) {
|
|
671
|
+
throw coded('unknown_group', `this Session has not joined "${groupId}"; run a2a_groups to list the groups it belongs to`);
|
|
672
|
+
}
|
|
673
|
+
// A group hosted elsewhere: the host owns the token table, so it mints. The owner credential travels
|
|
674
|
+
// with the call and the host verifies it — this instance does not gain authority it does not have.
|
|
675
|
+
if (membership.local !== true) {
|
|
676
|
+
const target = targetOf(membership, config);
|
|
677
|
+
// invite via bridge: the host is the only process that can mint a link it will itself accept.
|
|
678
|
+
const result = await bridge.send(target, {
|
|
679
|
+
skill: 'group/invite',
|
|
680
|
+
text: 'invite for ' + groupId,
|
|
681
|
+
data: {
|
|
682
|
+
groupId,
|
|
683
|
+
personId: membership.personId,
|
|
684
|
+
personKey: membership.personKey,
|
|
685
|
+
label: args.label,
|
|
686
|
+
expiresInHours: args.expiresInHours,
|
|
687
|
+
},
|
|
688
|
+
timeoutMs: config.client.timeoutMs,
|
|
689
|
+
});
|
|
690
|
+
const meta = result.data ?? {};
|
|
691
|
+
const link = typeof meta.link === 'string' ? meta.link : '';
|
|
692
|
+
if (link === '') {
|
|
693
|
+
throw coded('bad_response', 'the host did not return an invite link');
|
|
694
|
+
}
|
|
695
|
+
return {
|
|
696
|
+
groupId,
|
|
697
|
+
link,
|
|
698
|
+
prompt: typeof meta.prompt === 'string' ? meta.prompt : link,
|
|
699
|
+
expiresAt: meta.expiresAt,
|
|
700
|
+
inviteId: meta.inviteId,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const issued = store.invite({
|
|
705
|
+
groupId,
|
|
706
|
+
label: args.label,
|
|
707
|
+
expiresInHours: args.expiresInHours ?? config.groups.inviteExpiresInHours,
|
|
708
|
+
// The owner credential, not just the role: the store verifies it, so a
|
|
709
|
+
// display-field role being wrong cannot mint an invite.
|
|
710
|
+
ownerKey: membership.personKey,
|
|
711
|
+
gatewayBase: config.groups.gatewayBase === '' ? undefined : config.groups.gatewayBase,
|
|
712
|
+
agent: config.groups.agentName === '' ? undefined : config.groups.agentName,
|
|
713
|
+
});
|
|
714
|
+
await store.flush();
|
|
715
|
+
return {
|
|
716
|
+
link: issued.link,
|
|
717
|
+
prompt: issued.prompt,
|
|
718
|
+
expiresAt: new Date(issued.expiresAt).toISOString(),
|
|
719
|
+
neverExpires: issued.expiresAt === null,
|
|
720
|
+
};
|
|
721
|
+
},
|
|
722
|
+
'a2a_group_join': async function a2a_group_join(args, exec) {
|
|
723
|
+
// The Session that joins is the member: two Sessions of one instance may join
|
|
724
|
+
// the same group as two distinct members, each with its own credential.
|
|
725
|
+
const sessionId = requireSession(senderOf(exec).id, 'a2a_group_join');
|
|
726
|
+
const displayName = args.displayName ?? senderOf(exec).id ?? 'member';
|
|
727
|
+
// A link carries the gateway target as well as the group, so a remote group
|
|
728
|
+
// needs no separate registration step — that is the point of the link.
|
|
729
|
+
const parsed = typeof args.link === 'string' && args.link !== ''
|
|
730
|
+
? parseInviteLink(args.link)
|
|
731
|
+
: { groupId: requireText(args.groupId, 'groupId'), token: args.token, baseUrl: undefined, groupName: undefined };
|
|
732
|
+
|
|
733
|
+
const local = store.has?.(parsed.groupId) === true;
|
|
734
|
+
if (local) {
|
|
735
|
+
const joined = store.join({
|
|
736
|
+
groupId: parsed.groupId,
|
|
737
|
+
token: parsed.token,
|
|
738
|
+
displayName,
|
|
739
|
+
// Same reasoning as the remote path: this process knows its own address.
|
|
740
|
+
endpoint: ownEndpointFromConfig(config),
|
|
741
|
+
});
|
|
742
|
+
identities.upsert({
|
|
743
|
+
sessionId,
|
|
744
|
+
groupId: parsed.groupId,
|
|
745
|
+
personId: joined.member.personId,
|
|
746
|
+
personKey: joined.personKey,
|
|
747
|
+
local: true,
|
|
748
|
+
groupName: joined.group.name,
|
|
749
|
+
role: joined.member.role,
|
|
750
|
+
lastSeq: joined.group.lastSeq ?? 0,
|
|
751
|
+
});
|
|
752
|
+
await store.flush();
|
|
753
|
+
await identities.flush();
|
|
754
|
+
return {
|
|
755
|
+
groupId: parsed.groupId,
|
|
756
|
+
groupName: joined.group.name,
|
|
757
|
+
personId: joined.member.personId,
|
|
758
|
+
remote: false,
|
|
759
|
+
recentMessages: joined.feedTail.length,
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// The link already says which gateway and which agent host the group, so a
|
|
764
|
+
// colleague needs nothing configured on their side: paste the link, say the
|
|
765
|
+
// password. The configured gatewayBase is only a fallback for a hand-written
|
|
766
|
+
// link that omitted it.
|
|
767
|
+
// The link splits the address in two (`gw` + `agent`) because they are two facts; the ledger needs
|
|
768
|
+
// one callable string, so they are joined here rather than at every use site.
|
|
769
|
+
const baseUrl = parsed.endpoint
|
|
770
|
+
?? composeMemberBase(
|
|
771
|
+
parsed.gatewayBase ?? (config.groups.gatewayBase === '' ? undefined : config.groups.gatewayBase),
|
|
772
|
+
parsed.agent,
|
|
773
|
+
);
|
|
774
|
+
if (baseUrl === undefined || baseUrl === '') {
|
|
775
|
+
throw coded(
|
|
776
|
+
'missing_endpoint',
|
|
777
|
+
'this invite link carries no gateway address, and no groups.gatewayBase is configured to fall back on. Ask the group owner for a fresh link.',
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
const target = {
|
|
781
|
+
baseUrl,
|
|
782
|
+
apiKey: config.client.apiKey,
|
|
783
|
+
forwardProto: config.client.forwardProto,
|
|
784
|
+
protocolVersion: config.client.protocolVersion,
|
|
785
|
+
};
|
|
786
|
+
const result = await bridge.send(target, {
|
|
787
|
+
skill: 'group/join',
|
|
788
|
+
text: `join ${parsed.groupId}`,
|
|
789
|
+
data: {
|
|
790
|
+
groupId: parsed.groupId,
|
|
791
|
+
token: parsed.token,
|
|
792
|
+
displayName,
|
|
793
|
+
// Our own address, so the host can record where to reach us. Composed here rather than
|
|
794
|
+
// there: the host process only knows its own name, and composing from it recorded every
|
|
795
|
+
// member at the host's address.
|
|
796
|
+
//
|
|
797
|
+
// Omitted when unconfigured, which the host reads as "no address" — the honest answer,
|
|
798
|
+
// since nothing can dial a member that has no gateway name.
|
|
799
|
+
endpoint: ownEndpointFromConfig(config),
|
|
800
|
+
},
|
|
801
|
+
timeoutMs: config.client.timeoutMs,
|
|
802
|
+
});
|
|
803
|
+
const meta = result.data ?? {};
|
|
804
|
+
sessionId,
|
|
805
|
+
identities.upsert({
|
|
806
|
+
sessionId,
|
|
807
|
+
groupId: parsed.groupId,
|
|
808
|
+
personId: meta.personId,
|
|
809
|
+
personKey: meta.personKey,
|
|
810
|
+
local: false,
|
|
811
|
+
baseUrl,
|
|
812
|
+
groupName: meta.groupName ?? parsed.groupName,
|
|
813
|
+
role: 'member',
|
|
814
|
+
lastSeq: meta.lastSeq ?? 0,
|
|
815
|
+
});
|
|
816
|
+
await identities.flush();
|
|
817
|
+
if (inbox !== undefined) await inbox.enable(parsed.groupId);
|
|
818
|
+
return {
|
|
819
|
+
groupId: parsed.groupId,
|
|
820
|
+
groupName: meta.groupName ?? parsed.groupName ?? parsed.groupId,
|
|
821
|
+
personId: meta.personId,
|
|
822
|
+
remote: true,
|
|
823
|
+
recentMessages: Array.isArray(meta.feedTail) ? meta.feedTail.length : 0,
|
|
824
|
+
};
|
|
825
|
+
},
|
|
826
|
+
'a2a_group_post': async function a2a_group_post(args, exec) {
|
|
827
|
+
const sessionId = requireSession(senderOf(exec).id, 'a2a_group_post');
|
|
828
|
+
const groupId = requireText(args.groupId, 'groupId');
|
|
829
|
+
const message = requireText(args.message, 'message');
|
|
830
|
+
const membership = requireMembership(store, identities, groupId, sessionId);
|
|
831
|
+
if (membership.local === true) {
|
|
832
|
+
const posted = store.post({
|
|
833
|
+
groupId,
|
|
834
|
+
member: { personId: membership.personId, personKey: membership.personKey },
|
|
835
|
+
text: message,
|
|
836
|
+
});
|
|
837
|
+
await store.flush();
|
|
838
|
+
return { seq: posted.seq, groupId };
|
|
839
|
+
}
|
|
840
|
+
const result = await bridge.send(targetOf(membership, config), {
|
|
841
|
+
skill: 'group/post',
|
|
842
|
+
text: message,
|
|
843
|
+
data: {
|
|
844
|
+
groupId,
|
|
845
|
+
personId: membership.personId,
|
|
846
|
+
personKey: membership.personKey,
|
|
847
|
+
text: message,
|
|
848
|
+
mentions: args.mentions,
|
|
849
|
+
},
|
|
850
|
+
timeoutMs: config.client.timeoutMs,
|
|
851
|
+
});
|
|
852
|
+
return { seq: result.data?.seq ?? 0, groupId };
|
|
853
|
+
},
|
|
854
|
+
'a2a_group_role': async function a2a_group_role(args, exec) {
|
|
855
|
+
const sessionId = requireSession(senderOf(exec).id, 'a2a_group_read');
|
|
856
|
+
const groupId = requireText(args.groupId, 'groupId');
|
|
857
|
+
const membership = requireMembership(store, identities, groupId, sessionId);
|
|
858
|
+
// The member credential identifies the caller, so the store can only ever
|
|
859
|
+
// change this caller's own row. There is deliberately no `targetPersonId`:
|
|
860
|
+
// a role is what you say about yourself.
|
|
861
|
+
const data = {
|
|
862
|
+
groupId,
|
|
863
|
+
personId: membership.personId,
|
|
864
|
+
personKey: membership.personKey,
|
|
865
|
+
about: args.about,
|
|
866
|
+
tags: args.tags,
|
|
867
|
+
};
|
|
868
|
+
if (args.displayName !== undefined) data.displayName = args.displayName;
|
|
869
|
+
|
|
870
|
+
if (membership.local === true) {
|
|
871
|
+
const updated = store.setProfile({ groupId, member: data, about: data.about, tags: data.tags });
|
|
872
|
+
await store.flush();
|
|
873
|
+
return { groupId, about: updated.roleAbout ?? '', tags: updated.tags ?? [] };
|
|
874
|
+
}
|
|
875
|
+
const result = await bridge.send(targetOf(membership, config), {
|
|
876
|
+
skill: 'group/role',
|
|
877
|
+
text: `role in ${groupId}`,
|
|
878
|
+
data,
|
|
879
|
+
timeoutMs: config.client.timeoutMs,
|
|
880
|
+
});
|
|
881
|
+
const meta = result.data ?? {};
|
|
882
|
+
return { groupId, about: meta.about ?? '', tags: meta.tags ?? [] };
|
|
883
|
+
},
|
|
884
|
+
'a2a_group_info': async function a2a_group_info(args, exec) {
|
|
885
|
+
const groupId = requireText(args.groupId, 'groupId');
|
|
886
|
+
const sessionId = requireSession(senderOf(exec).id, 'a2a_group_info');
|
|
887
|
+
const membership = requireMembership(store, identities, groupId, sessionId);
|
|
888
|
+
|
|
889
|
+
if (membership.local === true) {
|
|
890
|
+
const snapshot = store.snapshot({
|
|
891
|
+
groupId,
|
|
892
|
+
member: { personId: membership.personId, personKey: membership.personKey },
|
|
893
|
+
});
|
|
894
|
+
return {
|
|
895
|
+
groupId,
|
|
896
|
+
name: snapshot.name,
|
|
897
|
+
topic: snapshot.topic,
|
|
898
|
+
memberCount: snapshot.memberCount,
|
|
899
|
+
onlineCount: snapshot.onlineCount,
|
|
900
|
+
lastSeq: snapshot.lastSeq,
|
|
901
|
+
isOwner: snapshot.owner?.personId === membership.personId,
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const target = targetOf(membership, config);
|
|
906
|
+
const result = await bridge.send(target, {
|
|
907
|
+
skill: 'group/info',
|
|
908
|
+
text: `info for ${groupId}`,
|
|
909
|
+
data: { groupId, personId: membership.personId, personKey: membership.personKey },
|
|
910
|
+
timeoutMs: config.client.timeoutMs,
|
|
911
|
+
});
|
|
912
|
+
const meta = result.data ?? {};
|
|
913
|
+
// Cache everything the panel needs: it reads the ledger, not the network.
|
|
914
|
+
// Without this a remote group keeps showing its id and "0 人", which reads as an
|
|
915
|
+
// empty group rather than one that has simply not been fetched yet.
|
|
916
|
+
identities.upsert({
|
|
917
|
+
sessionId,
|
|
918
|
+
groupId,
|
|
919
|
+
groupName: typeof meta.name === 'string' && meta.name !== '' ? meta.name : undefined,
|
|
920
|
+
groupTopic: typeof meta.topic === 'string' ? meta.topic : undefined,
|
|
921
|
+
memberCount: Number.isFinite(meta.memberCount) ? meta.memberCount : undefined,
|
|
922
|
+
onlineCount: Number.isFinite(meta.onlineCount) ? meta.onlineCount : undefined,
|
|
923
|
+
lastSeq: Number.isFinite(meta.lastSeq) ? meta.lastSeq : undefined,
|
|
924
|
+
lastTime: Number.isFinite(meta.lastPostAt) ? meta.lastPostAt : undefined,
|
|
925
|
+
isOwner: meta.isOwner === true,
|
|
926
|
+
// The roster is what makes a colleague card useful: without roleAbout and tags a card
|
|
927
|
+
// can only say "in N groups", which is the one thing the reader already knows.
|
|
928
|
+
members: Array.isArray(meta.members) ? meta.members : undefined,
|
|
929
|
+
rosterCachedAt: Array.isArray(meta.members) ? Date.now() : undefined,
|
|
930
|
+
});
|
|
931
|
+
await identities.flush();
|
|
932
|
+
return {
|
|
933
|
+
groupId,
|
|
934
|
+
name: meta.name ?? groupId,
|
|
935
|
+
topic: meta.topic ?? '',
|
|
936
|
+
memberCount: Number.isFinite(meta.memberCount) ? meta.memberCount : 0,
|
|
937
|
+
onlineCount: Number.isFinite(meta.onlineCount) ? meta.onlineCount : 0,
|
|
938
|
+
lastSeq: Number.isFinite(meta.lastSeq) ? meta.lastSeq : 0,
|
|
939
|
+
isOwner: meta.isOwner === true,
|
|
940
|
+
rosterCount: Array.isArray(meta.members) ? meta.members.length : 0,
|
|
941
|
+
};
|
|
942
|
+
},
|
|
943
|
+
'a2a_group_read': async function a2a_group_read(args, exec) {
|
|
944
|
+
const sessionId = requireSession(senderOf(exec).id, 'a2a_group_role');
|
|
945
|
+
const groupId = requireText(args.groupId, 'groupId');
|
|
946
|
+
const sinceSeq = args.sinceSeq ?? 0;
|
|
947
|
+
const limit = args.limit ?? 50;
|
|
948
|
+
const membership = requireMembership(store, identities, groupId, sessionId);
|
|
949
|
+
if (membership.local === true) {
|
|
950
|
+
const page = store.read({
|
|
951
|
+
groupId,
|
|
952
|
+
member: { personId: membership.personId, personKey: membership.personKey },
|
|
953
|
+
sinceSeq,
|
|
954
|
+
limit,
|
|
955
|
+
});
|
|
956
|
+
return {
|
|
957
|
+
lastSeq: page.lastSeq,
|
|
958
|
+
entries: page.entries.map((entry) => ({
|
|
959
|
+
seq: entry.seq,
|
|
960
|
+
from: entry.from.displayName,
|
|
961
|
+
text: entry.text,
|
|
962
|
+
at: entry.atIso,
|
|
963
|
+
})),
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
const result = await bridge.send(targetOf(membership, config), {
|
|
967
|
+
skill: 'group/read',
|
|
968
|
+
text: `read ${groupId} after ${sinceSeq}`,
|
|
969
|
+
data: { groupId, personId: membership.personId, personKey: membership.personKey, sinceSeq, limit },
|
|
970
|
+
});
|
|
971
|
+
const meta = result.data ?? {};
|
|
972
|
+
return {
|
|
973
|
+
lastSeq: meta.lastSeq ?? sinceSeq,
|
|
974
|
+
entries: (meta.entries ?? []).map((entry) => ({
|
|
975
|
+
seq: entry.seq,
|
|
976
|
+
from: entry.from,
|
|
977
|
+
text: entry.text,
|
|
978
|
+
at: entry.atIso ?? entry.at,
|
|
979
|
+
})),
|
|
980
|
+
};
|
|
981
|
+
},
|
|
982
|
+
'a2a_group_directory': async function a2a_group_directory(args, exec) {
|
|
983
|
+
const sessionId = requireSession(senderOf(exec).id, 'a2a_group_directory');
|
|
984
|
+
// Default to whatever host this instance already points at: the common case
|
|
985
|
+
// is "what is on OUR host", and requiring an address for that would be noise.
|
|
986
|
+
const reference = args.agent
|
|
987
|
+
?? (config.groups.agentName !== '' && config.groups.gatewayBase !== ''
|
|
988
|
+
? joinUrl(config.groups.gatewayBase, config.groups.agentName)
|
|
989
|
+
: undefined);
|
|
990
|
+
const target = resolveTarget(registry, config, requireText(reference, 'agent or groups.gatewayBase+agentName'));
|
|
991
|
+
const result = await bridge.send(target, {
|
|
992
|
+
skill: 'group/directory',
|
|
993
|
+
text: 'list groups',
|
|
994
|
+
data: {
|
|
995
|
+
query: args.query,
|
|
996
|
+
limit: args.limit,
|
|
997
|
+
},
|
|
998
|
+
timeoutMs: config.client.timeoutMs,
|
|
999
|
+
});
|
|
1000
|
+
const meta = result.data ?? {};
|
|
1001
|
+
return {
|
|
1002
|
+
total: meta.total ?? 0,
|
|
1003
|
+
hasMore: meta.hasMore === true,
|
|
1004
|
+
groups: (meta.groups ?? []).map((group) => ({
|
|
1005
|
+
groupId: group.groupId,
|
|
1006
|
+
name: group.name,
|
|
1007
|
+
topic: group.topic ?? '',
|
|
1008
|
+
memberCount: group.memberCount ?? 0,
|
|
1009
|
+
onlineCount: group.onlineCount ?? 0,
|
|
1010
|
+
lastActivityAt: group.lastActivityAtIso ?? '',
|
|
1011
|
+
})),
|
|
1012
|
+
};
|
|
1013
|
+
},
|
|
1014
|
+
'a2a_groups': async function a2a_groups(args, exec) {
|
|
1015
|
+
const sessionId = requireSession(senderOf(exec).id, 'a2a_groups');
|
|
1016
|
+
// Hosted groups come from the group store; joined ones from the ledger.
|
|
1017
|
+
// Both are "my groups" to a person, so they are merged rather than split.
|
|
1018
|
+
const hosted = safeList(store, identities);
|
|
1019
|
+
const rows = [];
|
|
1020
|
+
for (const record of identities.list({ sessionId })) {
|
|
1021
|
+
if (args.groupId !== undefined && record.groupId !== args.groupId) continue;
|
|
1022
|
+
const summary = hosted.get(record.groupId);
|
|
1023
|
+
rows.push({
|
|
1024
|
+
groupId: record.groupId,
|
|
1025
|
+
name: summary?.name ?? record.groupName ?? record.groupId,
|
|
1026
|
+
role: summary?.owner?.personId === record.personId ? 'owner' : record.role,
|
|
1027
|
+
members: summary?.memberCount ?? 0,
|
|
1028
|
+
lastSeq: summary?.lastSeq ?? record.lastSeq ?? 0,
|
|
1029
|
+
lastMessage: lastMessageOf(summary, record),
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
return { groups: rows };
|
|
1033
|
+
},
|
|
1034
|
+
'a2a_group_inbox': async function a2a_group_inbox(args, exec) {
|
|
1035
|
+
const sessionId = requireSession(senderOf(exec).id, 'a2a_group_requests');
|
|
1036
|
+
const action = args.action ?? 'status';
|
|
1037
|
+
if (action === 'on') await inbox.enable(args.groupId);
|
|
1038
|
+
if (action === 'off') await inbox.disable(args.groupId);
|
|
1039
|
+
return { enabled: inbox.enabled(), subscriptions: inbox.state() };
|
|
1040
|
+
},
|
|
1041
|
+
'a2a_group_request': async function a2a_group_request(args, exec) {
|
|
1042
|
+
const groupId = requireText(args.groupId, 'groupId');
|
|
1043
|
+
const displayName = requireText(args.displayName, 'displayName');
|
|
1044
|
+
// Applying happens BEFORE this Session is a member, so there is no membership
|
|
1045
|
+
// record to read an address from: the target comes from the caller or config.
|
|
1046
|
+
if (store.has?.(groupId) === true) {
|
|
1047
|
+
const issued = store.requestJoin({ groupId, displayName, reason: args.reason });
|
|
1048
|
+
await store.flush();
|
|
1049
|
+
return {
|
|
1050
|
+
requestId: issued.requestId,
|
|
1051
|
+
claimKey: issued.claimKey ?? '',
|
|
1052
|
+
groupName: issued.groupName,
|
|
1053
|
+
expiresAt: new Date(issued.expiresAt).toISOString(),
|
|
1054
|
+
duplicate: issued.duplicate === true,
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
const target = hostTarget(registry, config, args.agent);
|
|
1058
|
+
const result = await bridge.send(target, {
|
|
1059
|
+
skill: 'group/request',
|
|
1060
|
+
text: `apply to join ${groupId}`,
|
|
1061
|
+
data: { groupId, displayName, reason: args.reason },
|
|
1062
|
+
timeoutMs: config.client.timeoutMs,
|
|
1063
|
+
});
|
|
1064
|
+
const meta = result.data ?? {};
|
|
1065
|
+
return {
|
|
1066
|
+
requestId: meta.requestId ?? '',
|
|
1067
|
+
claimKey: meta.claimKey ?? '',
|
|
1068
|
+
groupName: meta.groupName ?? groupId,
|
|
1069
|
+
expiresAt: meta.expiresAt ?? '',
|
|
1070
|
+
duplicate: meta.duplicate === true,
|
|
1071
|
+
};
|
|
1072
|
+
},
|
|
1073
|
+
'a2a_group_requests': async function a2a_group_requests(args, exec) {
|
|
1074
|
+
const groupId = requireText(args.groupId, 'groupId');
|
|
1075
|
+
const sessionId = requireSession(senderOf(exec).id, 'a2a_group_requests');
|
|
1076
|
+
const membership = requireLocalMembership(identities, groupId, sessionId);
|
|
1077
|
+
const requests = store.listRequests({
|
|
1078
|
+
groupId,
|
|
1079
|
+
member: { personId: membership.personId, personKey: membership.personKey },
|
|
1080
|
+
status: args.status ?? 'pending',
|
|
1081
|
+
});
|
|
1082
|
+
return {
|
|
1083
|
+
count: requests.length,
|
|
1084
|
+
requests: requests.map((entry) => ({
|
|
1085
|
+
requestId: entry.requestId,
|
|
1086
|
+
displayName: entry.displayName,
|
|
1087
|
+
reason: entry.reason,
|
|
1088
|
+
status: entry.status,
|
|
1089
|
+
expiresAt: entry.expiresAtIso,
|
|
1090
|
+
})),
|
|
1091
|
+
};
|
|
1092
|
+
},
|
|
1093
|
+
'a2a_group_decide': async function a2a_group_decide(args, exec) {
|
|
1094
|
+
const groupId = requireText(args.groupId, 'groupId');
|
|
1095
|
+
const sessionId = requireSession(senderOf(exec).id, 'a2a_group_decide');
|
|
1096
|
+
const membership = requireLocalMembership(identities, groupId, sessionId);
|
|
1097
|
+
const decided = store.decideRequest({
|
|
1098
|
+
groupId,
|
|
1099
|
+
member: { personId: membership.personId, personKey: membership.personKey },
|
|
1100
|
+
requestId: requireText(args.requestId, 'requestId'),
|
|
1101
|
+
approve: args.approve !== false,
|
|
1102
|
+
});
|
|
1103
|
+
await store.flush();
|
|
1104
|
+
return {
|
|
1105
|
+
requestId: decided.requestId,
|
|
1106
|
+
status: decided.status,
|
|
1107
|
+
displayName: decided.displayName,
|
|
1108
|
+
};
|
|
1109
|
+
},
|
|
1110
|
+
'a2a_group_remove': async function a2a_group_remove(args, exec) {
|
|
1111
|
+
const groupId = requireText(args.groupId, 'groupId');
|
|
1112
|
+
const targetPersonId = requireText(args.targetPersonId, 'targetPersonId');
|
|
1113
|
+
const sessionId = requireSession(senderOf(exec).id, 'a2a_group_remove');
|
|
1114
|
+
const membership = requireMembership(store, identities, groupId, sessionId);
|
|
1115
|
+
if (membership.local === true) {
|
|
1116
|
+
const removed = store.removeMember({
|
|
1117
|
+
groupId,
|
|
1118
|
+
member: { personId: membership.personId, personKey: membership.personKey },
|
|
1119
|
+
targetPersonId,
|
|
1120
|
+
reason: args.reason,
|
|
1121
|
+
});
|
|
1122
|
+
await store.flush();
|
|
1123
|
+
return { groupId, personId: removed.personId, displayName: removed.displayName };
|
|
1124
|
+
}
|
|
1125
|
+
const result = await bridge.send(targetOf(membership, config), {
|
|
1126
|
+
skill: 'group/remove',
|
|
1127
|
+
text: `remove ${targetPersonId}`,
|
|
1128
|
+
data: { groupId, personId: membership.personId, personKey: membership.personKey, targetPersonId, reason: args.reason },
|
|
1129
|
+
timeoutMs: config.client.timeoutMs,
|
|
1130
|
+
});
|
|
1131
|
+
const meta = result.data ?? {};
|
|
1132
|
+
return { groupId, personId: meta.personId ?? targetPersonId, displayName: meta.displayName ?? targetPersonId };
|
|
1133
|
+
},
|
|
1134
|
+
|
|
1135
|
+
'a2a_group_rename': async function a2a_group_rename(args, exec) {
|
|
1136
|
+
const groupId = requireText(args.groupId, 'groupId');
|
|
1137
|
+
const sessionId = requireSession(senderOf(exec).id, 'a2a_group_rename');
|
|
1138
|
+
const membership = requireMembership(store, identities, groupId, sessionId);
|
|
1139
|
+
if (membership.local === true) {
|
|
1140
|
+
const renamed = store.renameGroup({
|
|
1141
|
+
groupId,
|
|
1142
|
+
member: { personId: membership.personId, personKey: membership.personKey },
|
|
1143
|
+
name: args.name,
|
|
1144
|
+
topic: args.topic,
|
|
1145
|
+
});
|
|
1146
|
+
await store.flush();
|
|
1147
|
+
return { groupId, name: renamed.name, topic: renamed.topic, changed: renamed.changed };
|
|
1148
|
+
}
|
|
1149
|
+
const result = await bridge.send(targetOf(membership, config), {
|
|
1150
|
+
skill: 'group/rename',
|
|
1151
|
+
text: 'rename',
|
|
1152
|
+
data: { groupId, personId: membership.personId, personKey: membership.personKey, name: args.name, topic: args.topic },
|
|
1153
|
+
timeoutMs: config.client.timeoutMs,
|
|
1154
|
+
});
|
|
1155
|
+
const meta = result.data ?? {};
|
|
1156
|
+
return { groupId, name: meta.name ?? '', topic: meta.topic ?? '', changed: Array.isArray(meta.changed) ? meta.changed : [] };
|
|
1157
|
+
},
|
|
1158
|
+
|
|
1159
|
+
'a2a_group_archive': async function a2a_group_archive(args, exec) {
|
|
1160
|
+
const groupId = requireText(args.groupId, 'groupId');
|
|
1161
|
+
const sessionId = requireSession(senderOf(exec).id, 'a2a_group_archive');
|
|
1162
|
+
const membership = requireMembership(store, identities, groupId, sessionId);
|
|
1163
|
+
if (membership.local === true) {
|
|
1164
|
+
const result = store.archiveGroup({
|
|
1165
|
+
groupId,
|
|
1166
|
+
member: { personId: membership.personId, personKey: membership.personKey },
|
|
1167
|
+
archived: args.archived !== false,
|
|
1168
|
+
});
|
|
1169
|
+
await store.flush();
|
|
1170
|
+
return { groupId, archived: result.archived };
|
|
1171
|
+
}
|
|
1172
|
+
const result = await bridge.send(targetOf(membership, config), {
|
|
1173
|
+
skill: 'group/archive',
|
|
1174
|
+
text: 'archive',
|
|
1175
|
+
data: { groupId, personId: membership.personId, personKey: membership.personKey, archived: args.archived !== false },
|
|
1176
|
+
timeoutMs: config.client.timeoutMs,
|
|
1177
|
+
});
|
|
1178
|
+
const meta = result.data ?? {};
|
|
1179
|
+
return { groupId, archived: meta.archived === true };
|
|
1180
|
+
},
|
|
1181
|
+
|
|
1182
|
+
'a2a_group_claim': async function a2a_group_claim(args, exec) {
|
|
1183
|
+
const groupId = requireText(args.groupId, 'groupId');
|
|
1184
|
+
const requestId = requireText(args.requestId, 'requestId');
|
|
1185
|
+
const claimKey = requireText(args.claimKey, 'claimKey');
|
|
1186
|
+
const sessionId = requireSession(senderOf(exec).id, 'a2a_group_claim');
|
|
1187
|
+
|
|
1188
|
+
if (store.has?.(groupId) === true) {
|
|
1189
|
+
const claimed = store.claimJoin({ groupId, requestId, claimKey });
|
|
1190
|
+
identities.upsert({
|
|
1191
|
+
sessionId,
|
|
1192
|
+
groupId,
|
|
1193
|
+
personId: claimed.member.personId,
|
|
1194
|
+
personKey: claimed.personKey,
|
|
1195
|
+
local: true,
|
|
1196
|
+
groupName: claimed.group.name,
|
|
1197
|
+
role: claimed.member.role,
|
|
1198
|
+
lastSeq: claimed.group.lastSeq ?? 0,
|
|
1199
|
+
});
|
|
1200
|
+
await store.flush();
|
|
1201
|
+
await identities.flush();
|
|
1202
|
+
return {
|
|
1203
|
+
groupId,
|
|
1204
|
+
groupName: claimed.group.name,
|
|
1205
|
+
personId: claimed.member.personId,
|
|
1206
|
+
remote: false,
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
const target = hostTarget(registry, config, args.agent);
|
|
1210
|
+
const result = await bridge.send(target, {
|
|
1211
|
+
skill: 'group/claim',
|
|
1212
|
+
text: `claim ${requestId}`,
|
|
1213
|
+
data: { groupId, requestId, claimKey, displayName: args.displayName },
|
|
1214
|
+
timeoutMs: config.client.timeoutMs,
|
|
1215
|
+
});
|
|
1216
|
+
const meta = result.data ?? {};
|
|
1217
|
+
identities.upsert({
|
|
1218
|
+
sessionId,
|
|
1219
|
+
groupId,
|
|
1220
|
+
personId: meta.personId,
|
|
1221
|
+
personKey: meta.personKey,
|
|
1222
|
+
local: false,
|
|
1223
|
+
baseUrl: target.baseUrl,
|
|
1224
|
+
groupName: meta.groupName ?? groupId,
|
|
1225
|
+
role: 'member',
|
|
1226
|
+
lastSeq: 0,
|
|
1227
|
+
});
|
|
1228
|
+
await identities.flush();
|
|
1229
|
+
if (inbox !== undefined) await inbox.enable(groupId);
|
|
1230
|
+
return {
|
|
1231
|
+
groupId,
|
|
1232
|
+
groupName: meta.groupName ?? groupId,
|
|
1233
|
+
personId: meta.personId,
|
|
1234
|
+
remote: true,
|
|
1235
|
+
};
|
|
1236
|
+
},
|
|
1237
|
+
'a2a_serve': async function a2a_serve(args, exec) {
|
|
1238
|
+
// Asking how this instance is exposed is the declaration of intent that
|
|
1239
|
+
// binds it: the Session asking is the Session this agent should answer for.
|
|
1240
|
+
const sender = senderOf(exec);
|
|
1241
|
+
if (sender.id !== undefined) sessions.offerBinding(sender.id);
|
|
1242
|
+
const info = server.info();
|
|
1243
|
+
const base = {
|
|
1244
|
+
enabled: info.attached === true,
|
|
1245
|
+
endpoint: info.url,
|
|
1246
|
+
cardUrl: `${String(info.url).replace(/\/a2a$/, '')}${info.card}`,
|
|
1247
|
+
tokenRequired: info.tokenConfigured === true,
|
|
1248
|
+
boundSessionId: sessions.resolveBoundSessionId() ?? '',
|
|
1249
|
+
};
|
|
1250
|
+
if (args.check === false || !base.enabled) {
|
|
1251
|
+
return { ...base, gatewayEntry: gatewayEntryFor(info, config) };
|
|
1252
|
+
}
|
|
1253
|
+
// The probe walks the same path a colleague's invite would. Reporting its
|
|
1254
|
+
// steps — not just a yes/no — is what turns "it does not work" into "this is
|
|
1255
|
+
// the one thing left to do".
|
|
1256
|
+
const report = await probeReachability({ config, server });
|
|
1257
|
+
const missingRegistration = report.checks.some((check) => check.step === 'gateway-registered' && !check.ok);
|
|
1258
|
+
return {
|
|
1259
|
+
...base,
|
|
1260
|
+
reachable: report.ok === true,
|
|
1261
|
+
steps: report.checks.map((check) => ({ step: check.step, ok: check.ok, detail: check.detail })),
|
|
1262
|
+
hint: report.hint,
|
|
1263
|
+
gatewayEntry: missingRegistration ? gatewayEntryFor(info, config) : '',
|
|
1264
|
+
};
|
|
1265
|
+
},
|
|
1266
|
+
};
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* Drop the local membership when the host says the credential is dead.
|
|
1270
|
+
*
|
|
1271
|
+
* A removed member has no way to find out otherwise: nothing pushes to them, their panel
|
|
1272
|
+
* keeps listing the group, and every action fails with a code they never see. Deleting the
|
|
1273
|
+
* record is what turns an invisible revocation into a visible one — the group disappears
|
|
1274
|
+
* from their panel, which is exactly what happened.
|
|
1275
|
+
*
|
|
1276
|
+
* Scoped to one (groupId, sessionId) pair. A failure on one group says nothing about the
|
|
1277
|
+
* others, and a broader sweep would turn one stale credential into a wiped ledger.
|
|
1278
|
+
*
|
|
1279
|
+
* @param {Error} error - the failure from the handler.
|
|
1280
|
+
* @param {object} args - the tool arguments, for the group id.
|
|
1281
|
+
* @param {object} caller - `{ sessionId }`.
|
|
1282
|
+
* @returns {boolean} whether a record was removed.
|
|
1283
|
+
*/
|
|
1284
|
+
function reapStaleMembership(error, args, caller) {
|
|
1285
|
+
const code = typeof error?.code === 'string' ? error.code : '';
|
|
1286
|
+
// `not_member` is the host saying "this credential is not one of mine".
|
|
1287
|
+
// `unknown_group` covers a group that was deleted, or a cache pointing at a host that
|
|
1288
|
+
// no longer has it.
|
|
1289
|
+
if (code !== 'not_member' && code !== 'unknown_group') return false;
|
|
1290
|
+
const groupId = typeof args?.groupId === 'string' ? args.groupId : '';
|
|
1291
|
+
const sessionId = typeof caller?.sessionId === 'string' ? caller.sessionId : '';
|
|
1292
|
+
if (groupId === '' || sessionId === '') return false;
|
|
1293
|
+
if (identities === undefined || identities === null || typeof identities.remove !== 'function') return false;
|
|
1294
|
+
try {
|
|
1295
|
+
const removed = identities.remove({ groupId, sessionId });
|
|
1296
|
+
if (removed > 0) {
|
|
1297
|
+
// flush() is fire-and-forget: the in-memory ledger is already correct, and making a
|
|
1298
|
+
// failed disk write turn a clear error into a different one would help nobody.
|
|
1299
|
+
if (typeof identities.flush === 'function') {
|
|
1300
|
+
Promise.resolve(identities.flush()).catch(() => {});
|
|
1301
|
+
}
|
|
1302
|
+
logger?.info?.(
|
|
1303
|
+
`a2a: dropped the local membership for ${groupId} after the host answered ${code}`,
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
return removed > 0;
|
|
1307
|
+
} catch {
|
|
1308
|
+
return false;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/**
|
|
1313
|
+
* Run one tool.
|
|
1314
|
+
*
|
|
1315
|
+
* @param {string} name - tool name.
|
|
1316
|
+
* @param {object} args - arguments, already validated by the adapter.
|
|
1317
|
+
* @param {object} [caller] - `{ sessionId }`.
|
|
1318
|
+
* @returns {Promise<object>} the tool result.
|
|
1319
|
+
*/
|
|
1320
|
+
async function call(name, args, caller) {
|
|
1321
|
+
const handler = HANDLERS[name];
|
|
1322
|
+
if (typeof handler !== 'function') throw new Error(`unknown tool: ${name}`);
|
|
1323
|
+
const normalised = normaliseCaller(caller);
|
|
1324
|
+
try {
|
|
1325
|
+
return await handler(args ?? {}, normalised);
|
|
1326
|
+
} catch (error) {
|
|
1327
|
+
// One funnel for "your membership is gone", so the twelve group tools do not each
|
|
1328
|
+
// need their own copy of the same eight lines.
|
|
1329
|
+
if (reapStaleMembership(error, args, normalised)) {
|
|
1330
|
+
// Annotate rather than replace: the caller still needs the original code, and the
|
|
1331
|
+
// new field is what a UI can use to say "you were removed" instead of "not a member".
|
|
1332
|
+
if (error !== null && typeof error === 'object') error.membershipDropped = true;
|
|
1333
|
+
}
|
|
1334
|
+
throw error;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/** Whether this runtime knows a tool. */
|
|
1339
|
+
function has(name) {
|
|
1340
|
+
return typeof HANDLERS[name] === 'function';
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/** Every tool name the runtime can run. */
|
|
1344
|
+
function names() {
|
|
1345
|
+
return Object.keys(HANDLERS);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
return { call, has, names };
|
|
1349
|
+
}
|