dsh-team 0.2.1
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/LICENSE +21 -0
- package/README.md +216 -0
- package/cordis.patch.yml +32 -0
- package/lib/client.js +4967 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +591 -0
- package/lib/index.js +2141 -0
- package/package.json +94 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,2141 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { z as z$1 } from "zod";
|
|
3
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
4
|
+
import { ReasoningEffortId, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
5
|
+
import { SessionId } from "@deepseek-ai/dsh-session";
|
|
6
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
7
|
+
import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
|
|
8
|
+
//#region src/config.ts
|
|
9
|
+
/**
|
|
10
|
+
* Deployment configuration for the team row. Every value is a cordis.yml knob;
|
|
11
|
+
* this plugin holds no hardcoded tunable.
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-team/config
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* The row's config schema; the loader validates before the service is built.
|
|
17
|
+
* Every key is defaulted, so a deployment may add the row with no options at
|
|
18
|
+
* all — the input type stays partial while the validated output is complete.
|
|
19
|
+
*/
|
|
20
|
+
const Config = z.object({
|
|
21
|
+
provider: z.string().default("spawn"),
|
|
22
|
+
maxTeammates: z.number().step(1).min(1).max(64).default(8),
|
|
23
|
+
maxRecentMessages: z.number().step(1).min(1).max(1e3).default(50),
|
|
24
|
+
maxChainHops: z.number().step(1).min(1).max(64).default(4),
|
|
25
|
+
maxChainRoundTrips: z.number().step(1).min(1).max(64).default(2),
|
|
26
|
+
maxWorkspaceEntries: z.number().step(1).min(1).max(500).default(32),
|
|
27
|
+
maxNoteChars: z.number().step(1).min(200).max(2e5).default(4e3)
|
|
28
|
+
});
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/contract.ts
|
|
31
|
+
/** The empty value every session without a team folds to. */
|
|
32
|
+
const EMPTY_TEAM_VIEW = {
|
|
33
|
+
active: false,
|
|
34
|
+
members: [],
|
|
35
|
+
tasks: [],
|
|
36
|
+
messages: [],
|
|
37
|
+
board: []
|
|
38
|
+
};
|
|
39
|
+
/** The projection key this plugin owns. */
|
|
40
|
+
const TEAM_PROJECTION_KEY = "team";
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/fold.ts
|
|
43
|
+
function asRecord(value) {
|
|
44
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
45
|
+
}
|
|
46
|
+
function asText(value) {
|
|
47
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
48
|
+
}
|
|
49
|
+
function asCount(value) {
|
|
50
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : void 0;
|
|
51
|
+
}
|
|
52
|
+
function asRelation(value) {
|
|
53
|
+
return value === "managed" || value === "peer" ? value : void 0;
|
|
54
|
+
}
|
|
55
|
+
function asStatus(value) {
|
|
56
|
+
return value === "pending" || value === "active" || value === "done" ? value : void 0;
|
|
57
|
+
}
|
|
58
|
+
/** Narrow one logged member fact, or reject it whole. */
|
|
59
|
+
function readMember(value) {
|
|
60
|
+
const record = asRecord(value);
|
|
61
|
+
if (record === void 0) return void 0;
|
|
62
|
+
const memberId = asText(record["memberId"]);
|
|
63
|
+
const name = asText(record["name"]);
|
|
64
|
+
const relation = asRelation(record["relation"]);
|
|
65
|
+
if (memberId === void 0 || name === void 0 || relation === void 0) return void 0;
|
|
66
|
+
const role = asText(record["role"]);
|
|
67
|
+
const model = asText(record["model"]);
|
|
68
|
+
const effort = asText(record["effort"]);
|
|
69
|
+
return {
|
|
70
|
+
memberId,
|
|
71
|
+
name,
|
|
72
|
+
relation,
|
|
73
|
+
...role !== void 0 ? { role } : {},
|
|
74
|
+
...model !== void 0 ? { model } : {},
|
|
75
|
+
...effort !== void 0 ? { effort } : {}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Narrow one logged task fact, or reject it whole. */
|
|
79
|
+
function readTask(value) {
|
|
80
|
+
const record = asRecord(value);
|
|
81
|
+
if (record === void 0) return void 0;
|
|
82
|
+
const taskId = asText(record["taskId"]);
|
|
83
|
+
const title = asText(record["title"]);
|
|
84
|
+
const status = asStatus(record["status"]);
|
|
85
|
+
if (taskId === void 0 || title === void 0 || status === void 0) return void 0;
|
|
86
|
+
const assigneeId = asText(record["assigneeId"]);
|
|
87
|
+
const note = asText(record["note"]);
|
|
88
|
+
return {
|
|
89
|
+
taskId,
|
|
90
|
+
title,
|
|
91
|
+
status,
|
|
92
|
+
...assigneeId !== void 0 ? { assigneeId } : {},
|
|
93
|
+
...note !== void 0 ? { note } : {}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** Narrow one logged board entry, or reject it whole. */
|
|
97
|
+
function readBoardEntry(value) {
|
|
98
|
+
const record = asRecord(value);
|
|
99
|
+
if (record === void 0) return void 0;
|
|
100
|
+
const key = asText(record["key"]);
|
|
101
|
+
const authorId = asText(record["authorId"]);
|
|
102
|
+
const authorName = asText(record["authorName"]);
|
|
103
|
+
const updatedAt = asCount(record["updatedAt"]);
|
|
104
|
+
if (key === void 0 || authorId === void 0 || authorName === void 0 || updatedAt === void 0) return;
|
|
105
|
+
return {
|
|
106
|
+
key,
|
|
107
|
+
authorId,
|
|
108
|
+
authorName,
|
|
109
|
+
updatedAt,
|
|
110
|
+
preview: asText(record["preview"]) ?? ""
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** Narrow one logged team fact off a `tool/result` event's `meta`. */
|
|
114
|
+
function readFact(meta) {
|
|
115
|
+
const record = asRecord(meta);
|
|
116
|
+
if (record === void 0) return void 0;
|
|
117
|
+
switch (record["team"]) {
|
|
118
|
+
case "member-added":
|
|
119
|
+
case "member-updated": {
|
|
120
|
+
const member = readMember(record["member"]);
|
|
121
|
+
return member === void 0 ? void 0 : {
|
|
122
|
+
team: record["team"] === "member-added" ? "member-added" : "member-updated",
|
|
123
|
+
member
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
case "member-removed": {
|
|
127
|
+
const memberId = asText(record["memberId"]);
|
|
128
|
+
return memberId === void 0 ? void 0 : {
|
|
129
|
+
team: "member-removed",
|
|
130
|
+
memberId
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
case "ended": return { team: "ended" };
|
|
134
|
+
case "message": {
|
|
135
|
+
const messageId = asText(record["messageId"]);
|
|
136
|
+
const to = asText(record["to"]);
|
|
137
|
+
if (messageId === void 0 || to === void 0) return void 0;
|
|
138
|
+
const hop = asCount(record["hop"]);
|
|
139
|
+
return {
|
|
140
|
+
team: "message",
|
|
141
|
+
messageId,
|
|
142
|
+
to,
|
|
143
|
+
text: asText(record["text"]) ?? "",
|
|
144
|
+
...hop !== void 0 ? { hop } : {}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
case "task": {
|
|
148
|
+
const task = readTask(record["task"]);
|
|
149
|
+
return task === void 0 ? void 0 : {
|
|
150
|
+
team: "task",
|
|
151
|
+
task
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
case "board": {
|
|
155
|
+
const rows = record["entries"];
|
|
156
|
+
const at = asCount(record["at"]);
|
|
157
|
+
if (!Array.isArray(rows) || at === void 0) return void 0;
|
|
158
|
+
return {
|
|
159
|
+
team: "board",
|
|
160
|
+
entries: rows.map(readBoardEntry).filter((entry) => entry !== void 0),
|
|
161
|
+
at
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
default: return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Narrow one delivered message's source into a mailbox row, or reject it.
|
|
169
|
+
*
|
|
170
|
+
* Three vocabularies reach a leader's log: this plugin's own `team-message`
|
|
171
|
+
* deliveries, and the harness's `subagent-report` / `subagent-settled` edges,
|
|
172
|
+
* which a teammate produces through the built-in `report` tool and through the
|
|
173
|
+
* end of its activation. The last two also arrive from ordinary subagents, so
|
|
174
|
+
* the caller keeps only senders that are on the roster.
|
|
175
|
+
*/
|
|
176
|
+
function readIncoming(source) {
|
|
177
|
+
const record = asRecord(source);
|
|
178
|
+
if (record === void 0) return void 0;
|
|
179
|
+
const kind = record["kind"];
|
|
180
|
+
if (kind !== "team-message" && kind !== "subagent-report" && kind !== "subagent-settled") return void 0;
|
|
181
|
+
const senderSessionId = asText(record["senderSessionId"]);
|
|
182
|
+
if (senderSessionId === void 0) return void 0;
|
|
183
|
+
const senderName = asText(record["senderName"]);
|
|
184
|
+
const hop = asCount(record["hop"]);
|
|
185
|
+
return {
|
|
186
|
+
senderSessionId,
|
|
187
|
+
...senderName !== void 0 ? { senderName } : {},
|
|
188
|
+
...hop !== void 0 ? { hop } : {},
|
|
189
|
+
kind: kind === "team-message" ? "message" : kind === "subagent-report" ? "report" : "settled"
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/** Append one row to the bounded feed. */
|
|
193
|
+
function appended(messages, row, bound) {
|
|
194
|
+
const next = [...messages, row];
|
|
195
|
+
return next.length > bound ? next.slice(next.length - bound) : next;
|
|
196
|
+
}
|
|
197
|
+
/** Replace one member in place, or append it when the id is new. */
|
|
198
|
+
function upsertMember(members, member) {
|
|
199
|
+
const index = members.findIndex((candidate) => candidate.memberId === member.memberId);
|
|
200
|
+
if (index < 0) return [...members, member];
|
|
201
|
+
const next = [...members];
|
|
202
|
+
next[index] = member;
|
|
203
|
+
return next;
|
|
204
|
+
}
|
|
205
|
+
/** Replace one task in place, or append it when the id is new. */
|
|
206
|
+
function upsertTask(tasks, task) {
|
|
207
|
+
const index = tasks.findIndex((candidate) => candidate.taskId === task.taskId);
|
|
208
|
+
if (index < 0) return [...tasks, task];
|
|
209
|
+
const next = [...tasks];
|
|
210
|
+
next[index] = task;
|
|
211
|
+
return next;
|
|
212
|
+
}
|
|
213
|
+
/** Apply one settled team fact to the view. */
|
|
214
|
+
function applyFact(view, fact, time, bound) {
|
|
215
|
+
switch (fact.team) {
|
|
216
|
+
case "member-added": return {
|
|
217
|
+
...view,
|
|
218
|
+
active: true,
|
|
219
|
+
members: upsertMember(view.members, {
|
|
220
|
+
...fact.member,
|
|
221
|
+
joinedAt: time
|
|
222
|
+
})
|
|
223
|
+
};
|
|
224
|
+
case "member-updated": {
|
|
225
|
+
const previous = view.members.find((candidate) => candidate.memberId === fact.member.memberId);
|
|
226
|
+
if (previous === void 0) return view;
|
|
227
|
+
return {
|
|
228
|
+
...view,
|
|
229
|
+
members: upsertMember(view.members, {
|
|
230
|
+
...fact.member,
|
|
231
|
+
joinedAt: previous.joinedAt
|
|
232
|
+
})
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
case "member-removed": return {
|
|
236
|
+
...view,
|
|
237
|
+
members: view.members.filter((candidate) => candidate.memberId !== fact.memberId)
|
|
238
|
+
};
|
|
239
|
+
case "ended": return {
|
|
240
|
+
active: false,
|
|
241
|
+
members: [],
|
|
242
|
+
tasks: [],
|
|
243
|
+
messages: view.messages,
|
|
244
|
+
board: []
|
|
245
|
+
};
|
|
246
|
+
case "message": return {
|
|
247
|
+
...view,
|
|
248
|
+
messages: appended(view.messages, {
|
|
249
|
+
messageId: fact.messageId,
|
|
250
|
+
to: fact.to,
|
|
251
|
+
kind: "message",
|
|
252
|
+
text: fact.text,
|
|
253
|
+
time,
|
|
254
|
+
...fact.hop !== void 0 ? { hop: fact.hop } : {}
|
|
255
|
+
}, bound)
|
|
256
|
+
};
|
|
257
|
+
case "task": return {
|
|
258
|
+
...view,
|
|
259
|
+
tasks: upsertTask(view.tasks, fact.task)
|
|
260
|
+
};
|
|
261
|
+
case "board": return {
|
|
262
|
+
...view,
|
|
263
|
+
board: fact.entries,
|
|
264
|
+
boardAt: fact.at
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Apply one inbound delivery to the view, keeping only senders the roster
|
|
270
|
+
* knows: a plain subagent's report is not team traffic.
|
|
271
|
+
*/
|
|
272
|
+
function applyIncoming(view, incoming, messageId, text, time, bound) {
|
|
273
|
+
if (!view.members.some((member) => member.memberId === incoming.senderSessionId)) return view;
|
|
274
|
+
return {
|
|
275
|
+
...view,
|
|
276
|
+
messages: appended(view.messages, {
|
|
277
|
+
messageId,
|
|
278
|
+
from: incoming.senderSessionId,
|
|
279
|
+
kind: incoming.kind,
|
|
280
|
+
text,
|
|
281
|
+
time,
|
|
282
|
+
...incoming.hop !== void 0 ? { hop: incoming.hop } : {}
|
|
283
|
+
}, bound)
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
/** One-line account carried by a `notice`-form source, when it has one. */
|
|
287
|
+
function noticeText(source) {
|
|
288
|
+
const record = asRecord(source);
|
|
289
|
+
return record === void 0 ? void 0 : asText(record["summary"]);
|
|
290
|
+
}
|
|
291
|
+
/** First readable text block of a logged message's content. */
|
|
292
|
+
function textOf(content) {
|
|
293
|
+
if (!Array.isArray(content)) return "";
|
|
294
|
+
for (const block of content) {
|
|
295
|
+
const record = asRecord(block);
|
|
296
|
+
if (record?.["type"] === "text") return asText(record["text"]) ?? "";
|
|
297
|
+
}
|
|
298
|
+
return "";
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Fold one committed event into the team view.
|
|
302
|
+
*
|
|
303
|
+
* Returns the SAME reference for every event this unit does not own — the
|
|
304
|
+
* projection registry treats reference equality as "no downstream work".
|
|
305
|
+
* @param view - the state covering all prior events.
|
|
306
|
+
* @param event - the next committed session event.
|
|
307
|
+
* @param bound - mailbox feed length ceiling.
|
|
308
|
+
* @returns the next view, or `view` itself when nothing changed.
|
|
309
|
+
*/
|
|
310
|
+
function applyTeamEvent(view, event, bound) {
|
|
311
|
+
if (event.type === "tool/result") {
|
|
312
|
+
if (event.data.error !== void 0) return view;
|
|
313
|
+
const fact = readFact(event.data.meta);
|
|
314
|
+
return fact === void 0 ? view : applyFact(view, fact, event.time, bound);
|
|
315
|
+
}
|
|
316
|
+
if (event.type === "user/message") {
|
|
317
|
+
const incoming = readIncoming(event.data.source);
|
|
318
|
+
if (incoming === void 0) return view;
|
|
319
|
+
const text = incoming.kind === "settled" ? noticeText(event.data.source) ?? textOf(event.data.content) : textOf(event.data.content);
|
|
320
|
+
return applyIncoming(view, incoming, event.data.id, text, event.time, bound);
|
|
321
|
+
}
|
|
322
|
+
return view;
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Fold a whole log — the cold path used by tests and by a service reading a
|
|
326
|
+
* session the projection registry has not driven.
|
|
327
|
+
* @param events - the session's events, in seq order.
|
|
328
|
+
* @param bound - mailbox feed length ceiling.
|
|
329
|
+
* @returns the folded view.
|
|
330
|
+
*/
|
|
331
|
+
function foldTeam(events, bound) {
|
|
332
|
+
let view = EMPTY_TEAM_VIEW;
|
|
333
|
+
for (const event of events) view = applyTeamEvent(view, event, bound);
|
|
334
|
+
return view;
|
|
335
|
+
}
|
|
336
|
+
//#endregion
|
|
337
|
+
//#region src/projection.ts
|
|
338
|
+
/**
|
|
339
|
+
* The `team` session-projection unit: the host is the only place the team fold
|
|
340
|
+
* runs, and the framework serves its value to the browser (list baselines,
|
|
341
|
+
* history tail pages, and `session/projection` push frames) with no client-side
|
|
342
|
+
* folding at all.
|
|
343
|
+
*
|
|
344
|
+
* @module dsh-team/projection
|
|
345
|
+
*/
|
|
346
|
+
/**
|
|
347
|
+
* Wire validation for the served value. The unit's state IS the value, so one
|
|
348
|
+
* schema covers the read side and the persisted-cache round trip.
|
|
349
|
+
*/
|
|
350
|
+
const teamViewSchema = z$1.object({
|
|
351
|
+
active: z$1.boolean(),
|
|
352
|
+
members: z$1.array(z$1.object({
|
|
353
|
+
memberId: z$1.string(),
|
|
354
|
+
name: z$1.string(),
|
|
355
|
+
role: z$1.string().optional(),
|
|
356
|
+
relation: z$1.union([z$1.literal("managed"), z$1.literal("peer")]),
|
|
357
|
+
model: z$1.string().optional(),
|
|
358
|
+
effort: z$1.string().optional(),
|
|
359
|
+
joinedAt: z$1.number()
|
|
360
|
+
})),
|
|
361
|
+
tasks: z$1.array(z$1.object({
|
|
362
|
+
taskId: z$1.string(),
|
|
363
|
+
title: z$1.string(),
|
|
364
|
+
assigneeId: z$1.string().optional(),
|
|
365
|
+
status: z$1.union([
|
|
366
|
+
z$1.literal("pending"),
|
|
367
|
+
z$1.literal("active"),
|
|
368
|
+
z$1.literal("done")
|
|
369
|
+
]),
|
|
370
|
+
note: z$1.string().optional()
|
|
371
|
+
})),
|
|
372
|
+
messages: z$1.array(z$1.object({
|
|
373
|
+
messageId: z$1.string(),
|
|
374
|
+
from: z$1.string().optional(),
|
|
375
|
+
to: z$1.string().optional(),
|
|
376
|
+
kind: z$1.union([
|
|
377
|
+
z$1.literal("message"),
|
|
378
|
+
z$1.literal("report"),
|
|
379
|
+
z$1.literal("settled")
|
|
380
|
+
]),
|
|
381
|
+
text: z$1.string(),
|
|
382
|
+
time: z$1.number(),
|
|
383
|
+
hop: z$1.number().optional()
|
|
384
|
+
})),
|
|
385
|
+
board: z$1.array(z$1.object({
|
|
386
|
+
key: z$1.string(),
|
|
387
|
+
authorId: z$1.string(),
|
|
388
|
+
authorName: z$1.string(),
|
|
389
|
+
updatedAt: z$1.number(),
|
|
390
|
+
preview: z$1.string()
|
|
391
|
+
})),
|
|
392
|
+
boardAt: z$1.number().optional()
|
|
393
|
+
});
|
|
394
|
+
/**
|
|
395
|
+
* Build the projection unit for one deployment's mailbox bound.
|
|
396
|
+
* @param maxRecentMessages - feed ceiling from the row config.
|
|
397
|
+
* @returns the registrable unit.
|
|
398
|
+
*/
|
|
399
|
+
function teamProjection(maxRecentMessages) {
|
|
400
|
+
return {
|
|
401
|
+
key: "team",
|
|
402
|
+
schema: teamViewSchema,
|
|
403
|
+
init: () => EMPTY_TEAM_VIEW,
|
|
404
|
+
apply: (state, event) => applyTeamEvent(state, event, maxRecentMessages),
|
|
405
|
+
view: (state) => state,
|
|
406
|
+
stateVersion: 3
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
//#endregion
|
|
410
|
+
//#region src/errors.ts
|
|
411
|
+
/** Stable sentence per code; `detail` appends the caller-specific part. */
|
|
412
|
+
const MESSAGES = {
|
|
413
|
+
NO_TEAM: "no team here yet — spawn a teammate with team_spawn to start one",
|
|
414
|
+
LEADER_AWAY: "your team is intact but its main session is not loaded right now, so nothing can be delivered through it. Your work is not lost: write what you have to the shared workspace with team_note, which needs nobody else, and stop — the leader reads it when it comes back",
|
|
415
|
+
NESTED_TEAM: "a teammate cannot lead its own team; ask your leader to spawn one instead",
|
|
416
|
+
MAX_TEAMMATES: "the team is full — dismiss a teammate before spawning another",
|
|
417
|
+
DUPLICATE_NAME: "a teammate with that name is already on the roster",
|
|
418
|
+
UNKNOWN_MEMBER: "no teammate with that id or name is on the roster",
|
|
419
|
+
UNKNOWN_TASK: "no task with that id is on the shared task list",
|
|
420
|
+
TASK_TITLE_REQUIRED: "a new task needs a title",
|
|
421
|
+
SELF_MESSAGE: "a member cannot message itself",
|
|
422
|
+
UNAUTHORIZED: "this team operation is not available to you",
|
|
423
|
+
UNKNOWN_EFFORT: "that reasoning effort is not offered by the selected model",
|
|
424
|
+
CHAIN_EXHAUSTED: "this conversation has relayed as far as it may between teammates — settle it yourself and report to the leader, which is never refused",
|
|
425
|
+
PING_PONG: "you have already said your piece to this member in this conversation — decide with what you have, or raise it with the leader",
|
|
426
|
+
REPEATED_MESSAGE: "you already sent that exact message in this conversation; sending it again changes nothing",
|
|
427
|
+
INVALID_NOTE_KEY: "that note name cannot be stored",
|
|
428
|
+
NOTE_TOO_LONG: "that note is longer than this workspace accepts — keep the conclusion, drop the transcript",
|
|
429
|
+
WORKSPACE_FULL: "this workspace area is full",
|
|
430
|
+
UNKNOWN_NOTE: "no note with that name is in this workspace area"
|
|
431
|
+
};
|
|
432
|
+
/** One refused team operation. */
|
|
433
|
+
var TeamError = class extends Error {
|
|
434
|
+
code;
|
|
435
|
+
detail;
|
|
436
|
+
/**
|
|
437
|
+
* @param code - the stable refusal code.
|
|
438
|
+
* @param detail - the caller-specific part appended to the stable sentence.
|
|
439
|
+
*/
|
|
440
|
+
constructor(code, detail) {
|
|
441
|
+
super(detail === void 0 ? MESSAGES[code] : `${MESSAGES[code]}: ${detail}`);
|
|
442
|
+
this.code = code;
|
|
443
|
+
this.detail = detail;
|
|
444
|
+
this.name = "TeamError";
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
//#endregion
|
|
448
|
+
//#region src/service.ts
|
|
449
|
+
/**
|
|
450
|
+
* The agent-team service: roster, mailbox routing with its relation-based
|
|
451
|
+
* authorization, the shared task list, and the team lifecycle.
|
|
452
|
+
*
|
|
453
|
+
* Teammates are NOT a private runtime. Each one is a continuable subagent of
|
|
454
|
+
* the leader (`ctx.subagents.startContinuable`), so the harness owns identity,
|
|
455
|
+
* residency, cold resume after a restart, interrupt, and the `origin:
|
|
456
|
+
* 'subagent'` session header that keeps a teammate out of the session tree and
|
|
457
|
+
* out of generic Host routing. What this service adds is what the subagent
|
|
458
|
+
* seam deliberately leaves out: named members, member-to-member delivery, and
|
|
459
|
+
* one shared task list.
|
|
460
|
+
*
|
|
461
|
+
* Delivery authority is always the leader's. `ctx.subagents` authorizes a
|
|
462
|
+
* follow-up against the durable direct-parent lineage only ("teams remain
|
|
463
|
+
* rejected until an explicit authority protocol has a production consumer"),
|
|
464
|
+
* and the leader IS every teammate's durable direct parent. A peer-to-peer
|
|
465
|
+
* message is therefore a leader-authorized delivery whose durable source names
|
|
466
|
+
* the real sender; `relation` decides who may ASK for it, never who may
|
|
467
|
+
* perform it.
|
|
468
|
+
*
|
|
469
|
+
* @module dsh-team/service
|
|
470
|
+
*/
|
|
471
|
+
/** How many recent conversation chains one team keeps enforcement state for. */
|
|
472
|
+
const CHAIN_MEMORY = 64;
|
|
473
|
+
/** `Context.team`: roster, mailbox routing, tasks, and team lifecycle. */
|
|
474
|
+
var TeamService = class extends Service {
|
|
475
|
+
config;
|
|
476
|
+
static inject = [
|
|
477
|
+
"agents",
|
|
478
|
+
"sessions",
|
|
479
|
+
"subagents",
|
|
480
|
+
"sessionProjections"
|
|
481
|
+
];
|
|
482
|
+
/** Live team per leader session id; rebuilt lazily from that session's log. */
|
|
483
|
+
teams = /* @__PURE__ */ new Map();
|
|
484
|
+
/** Spawns in flight, keyed by leader session id (team tools never overlap). */
|
|
485
|
+
pending = /* @__PURE__ */ new Map();
|
|
486
|
+
constructor(ctx, config) {
|
|
487
|
+
super(ctx, "team");
|
|
488
|
+
this.config = config;
|
|
489
|
+
ctx.on("agent/disposed", (payload) => {
|
|
490
|
+
this.teams.delete(payload.agent.id);
|
|
491
|
+
this.pending.delete(payload.agent.id);
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* The live team of one leader, rebuilt from its log on first touch. Nothing
|
|
496
|
+
* is resumed here: a teammate materializes only when a message reaches it.
|
|
497
|
+
* @param leader - the leader session's live agent.
|
|
498
|
+
* @returns the mutable live team state.
|
|
499
|
+
*/
|
|
500
|
+
teamOf(leader) {
|
|
501
|
+
const existing = this.teams.get(leader.id);
|
|
502
|
+
if (existing !== void 0) return existing;
|
|
503
|
+
const view = this.durableView(leader);
|
|
504
|
+
const state = {
|
|
505
|
+
active: view.active,
|
|
506
|
+
members: new Map(view.members.map((member) => [member.memberId, memberFact(member)])),
|
|
507
|
+
tasks: new Map(view.tasks.map((task) => [task.taskId, task])),
|
|
508
|
+
inbox: /* @__PURE__ */ new Map(),
|
|
509
|
+
chains: /* @__PURE__ */ new Map(),
|
|
510
|
+
started: 0
|
|
511
|
+
};
|
|
512
|
+
this.teams.set(leader.id, state);
|
|
513
|
+
return state;
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Adopt one continuable child into the team world while its scope is being
|
|
517
|
+
* composed. Called from the teammate setup contribution, which runs inside
|
|
518
|
+
* the child's unpublished creation window — on cold resume the child is
|
|
519
|
+
* already on the leader's roster, and only a child the roster has never seen
|
|
520
|
+
* can be the spawn currently in flight.
|
|
521
|
+
* @param child - the unpublished child agent.
|
|
522
|
+
* @returns the membership facts, or undefined for a child outside any team.
|
|
523
|
+
*/
|
|
524
|
+
adopt(child) {
|
|
525
|
+
const leaderId = child.session.header.parentSession;
|
|
526
|
+
if (leaderId === void 0) return void 0;
|
|
527
|
+
const leader = this.ctx.agents.get(leaderId);
|
|
528
|
+
if (leader === void 0) return void 0;
|
|
529
|
+
const team = this.teamOf(leader);
|
|
530
|
+
const known = team.members.get(child.id);
|
|
531
|
+
if (known !== void 0) return known;
|
|
532
|
+
const pending = this.pending.get(leaderId);
|
|
533
|
+
if (pending === void 0) return void 0;
|
|
534
|
+
const fact = {
|
|
535
|
+
...pending.fact,
|
|
536
|
+
memberId: child.id
|
|
537
|
+
};
|
|
538
|
+
pending.claimed.push(child.id);
|
|
539
|
+
team.members.set(child.id, fact);
|
|
540
|
+
return fact;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Spawn one teammate: a continuable subagent of the leader plus a roster row.
|
|
544
|
+
* @param leader - the acting leader agent.
|
|
545
|
+
* @param request - name, relation, initial task, and optional overrides.
|
|
546
|
+
* @param signal - cancellation owning the operation until the teammate accepts its brief.
|
|
547
|
+
* @returns the new member's durable facts.
|
|
548
|
+
* @throws {TeamError} when the actor cannot lead, or the roster is full.
|
|
549
|
+
*/
|
|
550
|
+
async spawn(leader, request, signal) {
|
|
551
|
+
if (leader.session.header.origin === "subagent") throw new TeamError("NESTED_TEAM");
|
|
552
|
+
const team = this.teamOf(leader);
|
|
553
|
+
if (team.members.size >= this.config.maxTeammates) throw new TeamError("MAX_TEAMMATES", String(this.config.maxTeammates));
|
|
554
|
+
if (findByName(team, request.name) !== void 0) throw new TeamError("DUPLICATE_NAME", request.name);
|
|
555
|
+
await this.assertEffortOffered(leader, request);
|
|
556
|
+
const agentOptions = { ...request.model !== void 0 ? { model: request.model } : {} };
|
|
557
|
+
const pending = {
|
|
558
|
+
fact: {
|
|
559
|
+
name: request.name,
|
|
560
|
+
relation: request.relation,
|
|
561
|
+
...request.role !== void 0 ? { role: request.role } : {},
|
|
562
|
+
...request.model !== void 0 ? { model: request.model } : {},
|
|
563
|
+
...request.reasoningEffort !== void 0 ? { effort: request.reasoningEffort } : {}
|
|
564
|
+
},
|
|
565
|
+
claimed: []
|
|
566
|
+
};
|
|
567
|
+
this.pending.set(leader.id, pending);
|
|
568
|
+
let childId;
|
|
569
|
+
try {
|
|
570
|
+
childId = (await this.ctx.subagents.startContinuable({
|
|
571
|
+
provider: this.config.provider,
|
|
572
|
+
label: request.role === void 0 ? request.name : `${request.name} (${request.role})`,
|
|
573
|
+
request: {
|
|
574
|
+
parent: leader,
|
|
575
|
+
prompt: [{
|
|
576
|
+
type: "text",
|
|
577
|
+
text: brief(request)
|
|
578
|
+
}],
|
|
579
|
+
...request.persona !== void 0 ? { persona: request.persona } : {},
|
|
580
|
+
...Object.keys(agentOptions).length > 0 ? { agentOptions } : {}
|
|
581
|
+
},
|
|
582
|
+
signal
|
|
583
|
+
})).childId;
|
|
584
|
+
} catch (error) {
|
|
585
|
+
for (const claimed of pending.claimed) team.members.delete(claimed);
|
|
586
|
+
throw error;
|
|
587
|
+
} finally {
|
|
588
|
+
this.pending.delete(leader.id);
|
|
589
|
+
}
|
|
590
|
+
const fact = {
|
|
591
|
+
...pending.fact,
|
|
592
|
+
memberId: childId
|
|
593
|
+
};
|
|
594
|
+
team.members.set(childId, fact);
|
|
595
|
+
team.active = true;
|
|
596
|
+
this.ctx.emit("team/changed", { leaderId: leader.id });
|
|
597
|
+
return fact;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Deliver one mailbox message. The leader may message any teammate; a peer
|
|
601
|
+
* teammate may message the leader or any other teammate; a managed teammate
|
|
602
|
+
* may message only the leader.
|
|
603
|
+
*
|
|
604
|
+
* A teammate-to-teammate delivery also spends chain budget: it continues the
|
|
605
|
+
* conversation its sender is working from, and that conversation may only
|
|
606
|
+
* relay so far and may not repeat one ordered pair. Escalation to the leader
|
|
607
|
+
* spends nothing, so the guard converges a peer exchange without ever
|
|
608
|
+
* trapping a member with something to say.
|
|
609
|
+
* @param from - the acting agent (leader or teammate).
|
|
610
|
+
* @param to - recipient member id or member name; `leader` addresses the leader.
|
|
611
|
+
* @param text - the message content.
|
|
612
|
+
* @param signal - cancellation owning the delivery until inbox acceptance.
|
|
613
|
+
* @returns the accepted message id, the resolved recipient, and the chain it joined.
|
|
614
|
+
* @throws {TeamError} when the actor is outside the team, the recipient is
|
|
615
|
+
* unknown, the actor's relation forbids the delivery, or the conversation
|
|
616
|
+
* has spent its budget.
|
|
617
|
+
*/
|
|
618
|
+
async send(from, to, text, signal) {
|
|
619
|
+
const actor = this.resolveActor(from);
|
|
620
|
+
const recipient = this.resolveRecipient(actor, to);
|
|
621
|
+
if (actor.member !== void 0 && actor.member.relation === "managed" && recipient.kind !== "leader") throw new TeamError("UNAUTHORIZED", `${actor.member.name} is a managed teammate and may only message the leader`);
|
|
622
|
+
if (recipient.id === from.id) throw new TeamError("SELF_MESSAGE");
|
|
623
|
+
const chain = this.chainFor(actor, from);
|
|
624
|
+
if (recipient.kind === "member") this.assertBudget(actor.team, chain, from.id, recipient, text);
|
|
625
|
+
const messageId = await this.deliver(actor, recipient, [{
|
|
626
|
+
type: "text",
|
|
627
|
+
text
|
|
628
|
+
}], signal, chain);
|
|
629
|
+
this.recordDelivery(actor.team, chain, from.id, recipient, text);
|
|
630
|
+
return {
|
|
631
|
+
messageId,
|
|
632
|
+
recipient,
|
|
633
|
+
chain
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Create or update one shared task. Writes are the leader's: a teammate's
|
|
638
|
+
* own tool calls land in its own log, which the leader's durable team state
|
|
639
|
+
* never reads — teammates report, and the leader records the outcome.
|
|
640
|
+
* @param leader - the acting leader agent.
|
|
641
|
+
* @param spec - a new task (title) or an update to an existing one (taskId).
|
|
642
|
+
* @returns the whole post-change task.
|
|
643
|
+
* @throws {TeamError} when the actor is not the leader, the task is unknown,
|
|
644
|
+
* or the assignee is not on the roster.
|
|
645
|
+
*/
|
|
646
|
+
upsertTask(leader, spec) {
|
|
647
|
+
const actor = this.resolveActor(leader);
|
|
648
|
+
if (actor.member !== void 0) throw new TeamError("UNAUTHORIZED", "only the leader writes the task list");
|
|
649
|
+
const assigneeId = spec.assigneeId === void 0 ? void 0 : this.resolveRecipient(actor, spec.assigneeId).id;
|
|
650
|
+
if (spec.taskId === void 0) {
|
|
651
|
+
if (spec.title === void 0) throw new TeamError("TASK_TITLE_REQUIRED");
|
|
652
|
+
const task = {
|
|
653
|
+
taskId: taskIdOf(actor.team),
|
|
654
|
+
title: spec.title,
|
|
655
|
+
status: spec.status ?? "pending",
|
|
656
|
+
...assigneeId !== void 0 ? { assigneeId } : {},
|
|
657
|
+
...spec.note !== void 0 ? { note: spec.note } : {}
|
|
658
|
+
};
|
|
659
|
+
actor.team.tasks.set(task.taskId, task);
|
|
660
|
+
this.ctx.emit("team/changed", { leaderId: actor.leader.id });
|
|
661
|
+
return task;
|
|
662
|
+
}
|
|
663
|
+
const previous = actor.team.tasks.get(spec.taskId);
|
|
664
|
+
if (previous === void 0) throw new TeamError("UNKNOWN_TASK", spec.taskId);
|
|
665
|
+
const task = {
|
|
666
|
+
...previous,
|
|
667
|
+
...spec.title !== void 0 ? { title: spec.title } : {},
|
|
668
|
+
...spec.status !== void 0 ? { status: spec.status } : {},
|
|
669
|
+
...assigneeId !== void 0 ? { assigneeId } : {},
|
|
670
|
+
...spec.note !== void 0 ? { note: spec.note } : {}
|
|
671
|
+
};
|
|
672
|
+
actor.team.tasks.set(task.taskId, task);
|
|
673
|
+
this.ctx.emit("team/changed", { leaderId: actor.leader.id });
|
|
674
|
+
return task;
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Change one teammate's relationship level.
|
|
678
|
+
* @param leader - the acting leader agent.
|
|
679
|
+
* @param target - the teammate's member id or name.
|
|
680
|
+
* @param relation - the new relation.
|
|
681
|
+
* @returns the whole post-change member record.
|
|
682
|
+
* @throws {TeamError} when the actor is not the leader or the member is unknown.
|
|
683
|
+
*/
|
|
684
|
+
setRelation(leader, target, relation) {
|
|
685
|
+
const actor = this.resolveActor(leader);
|
|
686
|
+
if (actor.member !== void 0) throw new TeamError("UNAUTHORIZED", "only the leader changes relations");
|
|
687
|
+
const recipient = this.resolveRecipient(actor, target);
|
|
688
|
+
if (recipient.kind !== "member") throw new TeamError("UNKNOWN_MEMBER", target);
|
|
689
|
+
const fact = {
|
|
690
|
+
...recipient.member,
|
|
691
|
+
relation
|
|
692
|
+
};
|
|
693
|
+
actor.team.members.set(fact.memberId, fact);
|
|
694
|
+
this.ctx.emit("team/changed", { leaderId: actor.leader.id });
|
|
695
|
+
return fact;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Dismiss one teammate, or end the whole team when no target is given. A
|
|
699
|
+
* dismissed teammate stops its current turn and stops receiving mail; its
|
|
700
|
+
* durable session stays readable through the subagent catalog.
|
|
701
|
+
* @param leader - the acting leader agent.
|
|
702
|
+
* @param target - the teammate's member id or name; absent ends the team.
|
|
703
|
+
* @returns whether the team ended, plus the dismissed member id when targeted.
|
|
704
|
+
* @throws {TeamError} when the actor is not the leader or the member is unknown.
|
|
705
|
+
*/
|
|
706
|
+
dismiss(leader, target) {
|
|
707
|
+
const actor = this.resolveActor(leader);
|
|
708
|
+
if (actor.member !== void 0) throw new TeamError("UNAUTHORIZED", "only the leader dismisses teammates");
|
|
709
|
+
if (target === void 0) {
|
|
710
|
+
for (const memberId of [...actor.team.members.keys()]) this.stopMember(actor.leader, memberId);
|
|
711
|
+
actor.team.members.clear();
|
|
712
|
+
actor.team.tasks.clear();
|
|
713
|
+
actor.team.inbox.clear();
|
|
714
|
+
actor.team.chains.clear();
|
|
715
|
+
actor.team.active = false;
|
|
716
|
+
this.ctx.emit("team/changed", {
|
|
717
|
+
leaderId: actor.leader.id,
|
|
718
|
+
ended: true
|
|
719
|
+
});
|
|
720
|
+
return { ended: true };
|
|
721
|
+
}
|
|
722
|
+
const recipient = this.resolveRecipient(actor, target);
|
|
723
|
+
if (recipient.kind !== "member") throw new TeamError("UNKNOWN_MEMBER", target);
|
|
724
|
+
this.stopMember(actor.leader, recipient.id);
|
|
725
|
+
actor.team.members.delete(recipient.id);
|
|
726
|
+
actor.team.inbox.delete(recipient.id);
|
|
727
|
+
this.ctx.emit("team/changed", {
|
|
728
|
+
leaderId: actor.leader.id,
|
|
729
|
+
removedMember: recipient.id
|
|
730
|
+
});
|
|
731
|
+
return {
|
|
732
|
+
ended: false,
|
|
733
|
+
memberId: recipient.id
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* The roster, task list, and recent leader-visible mailbox traffic, from the
|
|
738
|
+
* point of view of any member of the team.
|
|
739
|
+
* @param actorAgent - the acting leader or teammate.
|
|
740
|
+
* @returns the live team read; an inactive team reports empty lists.
|
|
741
|
+
*/
|
|
742
|
+
list(actorAgent) {
|
|
743
|
+
const actor = this.tryResolveActor(actorAgent);
|
|
744
|
+
if (actor === void 0) return {
|
|
745
|
+
active: false,
|
|
746
|
+
members: [],
|
|
747
|
+
tasks: [],
|
|
748
|
+
messages: []
|
|
749
|
+
};
|
|
750
|
+
const view = this.durableView(actor.leader);
|
|
751
|
+
return {
|
|
752
|
+
active: actor.team.active,
|
|
753
|
+
members: [...actor.team.members.values()].map((member) => ({
|
|
754
|
+
...member,
|
|
755
|
+
joinedAt: view.members.find((row) => row.memberId === member.memberId)?.joinedAt ?? 0,
|
|
756
|
+
status: this.statusOf(member.memberId)
|
|
757
|
+
})),
|
|
758
|
+
tasks: [...actor.team.tasks.values()],
|
|
759
|
+
messages: [...view.messages]
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Where one acting agent sits in its team: whose workspace it reaches, and
|
|
764
|
+
* how a note it writes is attributed. The leader's own seat carries its
|
|
765
|
+
* session id, so its private pad is addressed exactly like a teammate's.
|
|
766
|
+
* @param agent - the acting leader or teammate.
|
|
767
|
+
* @returns the team's leader id, the actor's own id, and its display name.
|
|
768
|
+
* @throws {TeamError} when the actor is not in a team.
|
|
769
|
+
*/
|
|
770
|
+
seatOf(agent) {
|
|
771
|
+
const actor = this.resolveActor(agent);
|
|
772
|
+
return {
|
|
773
|
+
leaderId: actor.leader.id,
|
|
774
|
+
memberId: actor.member?.memberId ?? actor.leader.id,
|
|
775
|
+
name: actor.member?.name ?? "leader"
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* The teammate roster as one teammate should see it, for its prompt section.
|
|
780
|
+
* @param member - the teammate agent.
|
|
781
|
+
* @returns the leader-relative roster, or undefined outside a team.
|
|
782
|
+
*/
|
|
783
|
+
rosterFor(member) {
|
|
784
|
+
const actor = this.tryResolveActor(member);
|
|
785
|
+
if (actor?.member === void 0) return void 0;
|
|
786
|
+
return {
|
|
787
|
+
self: actor.member,
|
|
788
|
+
others: [...actor.team.members.values()].filter((row) => row.memberId !== member.id)
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
/** The durable view the leader's log folds to (the projection registry's cached cut). */
|
|
792
|
+
durableView(leader) {
|
|
793
|
+
const registry = this.ctx.get("sessionProjections");
|
|
794
|
+
if (registry === void 0) return foldTeam(leader.session.events, this.config.maxRecentMessages);
|
|
795
|
+
return registry.snapshot(leader.session).values.team ?? EMPTY_TEAM_VIEW;
|
|
796
|
+
}
|
|
797
|
+
/** Live runtime state of one teammate; `ready` means no live agent remains. */
|
|
798
|
+
statusOf(memberId) {
|
|
799
|
+
const agent = this.ctx.agents.get(SessionId(memberId));
|
|
800
|
+
if (agent === void 0) return "ready";
|
|
801
|
+
return agent.status === "running" ? "running" : "idle";
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Resolve the acting agent's team, or fail loud — and say WHICH failure it
|
|
805
|
+
* is. A teammate whose leader session is simply not loaded is not a teammate
|
|
806
|
+
* without a team: every delivery runs on the leader's parent authority, so
|
|
807
|
+
* the mailbox is shut until the leader is back, while the workspace (which
|
|
808
|
+
* needs nobody) stays open. Telling it "no team here yet" would send it to
|
|
809
|
+
* spawn one, which it cannot do.
|
|
810
|
+
*/
|
|
811
|
+
resolveActor(agent) {
|
|
812
|
+
const actor = this.tryResolveActor(agent);
|
|
813
|
+
if (actor !== void 0) return actor;
|
|
814
|
+
const leaderId = agent.session.header.parentSession;
|
|
815
|
+
if (agent.session.header.origin === "subagent" && leaderId !== void 0 && this.ctx.agents.get(leaderId) === void 0) throw new TeamError("LEADER_AWAY");
|
|
816
|
+
throw new TeamError("NO_TEAM");
|
|
817
|
+
}
|
|
818
|
+
/** Resolve the acting agent's team, or report absence. */
|
|
819
|
+
tryResolveActor(agent) {
|
|
820
|
+
if (agent.session.header.origin !== "subagent") return {
|
|
821
|
+
leader: agent,
|
|
822
|
+
team: this.teamOf(agent)
|
|
823
|
+
};
|
|
824
|
+
const leaderId = agent.session.header.parentSession;
|
|
825
|
+
if (leaderId === void 0) return void 0;
|
|
826
|
+
const leader = this.ctx.agents.get(leaderId);
|
|
827
|
+
if (leader === void 0) return void 0;
|
|
828
|
+
const team = this.teamOf(leader);
|
|
829
|
+
const member = team.members.get(agent.id);
|
|
830
|
+
return member === void 0 ? void 0 : {
|
|
831
|
+
leader,
|
|
832
|
+
team,
|
|
833
|
+
member
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
/** Resolve one address (member id, member name, or `leader`) to a recipient. */
|
|
837
|
+
resolveRecipient(actor, address) {
|
|
838
|
+
const normalized = address.trim();
|
|
839
|
+
if (normalized.length === 0) throw new TeamError("UNKNOWN_MEMBER", address);
|
|
840
|
+
if (normalized === actor.leader.id || normalized.toLowerCase() === "leader") return {
|
|
841
|
+
kind: "leader",
|
|
842
|
+
id: actor.leader.id,
|
|
843
|
+
name: "leader"
|
|
844
|
+
};
|
|
845
|
+
const byId = actor.team.members.get(normalized);
|
|
846
|
+
if (byId !== void 0) return {
|
|
847
|
+
kind: "member",
|
|
848
|
+
id: byId.memberId,
|
|
849
|
+
name: byId.name,
|
|
850
|
+
member: byId
|
|
851
|
+
};
|
|
852
|
+
const byName = findByName(actor.team, normalized);
|
|
853
|
+
if (byName !== void 0) return {
|
|
854
|
+
kind: "member",
|
|
855
|
+
id: byName.memberId,
|
|
856
|
+
name: byName.name,
|
|
857
|
+
member: byName
|
|
858
|
+
};
|
|
859
|
+
throw new TeamError("UNKNOWN_MEMBER", address);
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* The chain one send belongs to. A teammate continues the conversation it is
|
|
863
|
+
* working from — that is what turns a peer exchange into one bounded
|
|
864
|
+
* conversation instead of an unbounded sequence of unrelated messages. The
|
|
865
|
+
* leader always opens a fresh chain: its own turns are the user-visible,
|
|
866
|
+
* interruptible convergence point, so a conversation that reached the leader
|
|
867
|
+
* has already converged and the next instruction starts over.
|
|
868
|
+
*/
|
|
869
|
+
chainFor(actor, from) {
|
|
870
|
+
const inherited = actor.member === void 0 ? void 0 : actor.team.inbox.get(from.id);
|
|
871
|
+
if (inherited !== void 0) return {
|
|
872
|
+
chainId: inherited.chainId,
|
|
873
|
+
hop: inherited.hop + 1
|
|
874
|
+
};
|
|
875
|
+
actor.team.started += 1;
|
|
876
|
+
return {
|
|
877
|
+
chainId: `c${actor.team.started}`,
|
|
878
|
+
hop: 0
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Refuse a peer delivery this conversation can no longer afford: too many
|
|
883
|
+
* relays deep, one ordered pair talked out, or a verbatim repeat. Nothing
|
|
884
|
+
* here applies to a message addressed to the leader.
|
|
885
|
+
*/
|
|
886
|
+
assertBudget(team, chain, fromId, recipient, text) {
|
|
887
|
+
if (chain.hop > this.config.maxChainHops) throw new TeamError("CHAIN_EXHAUSTED", `relay ${chain.hop} of at most ${this.config.maxChainHops}`);
|
|
888
|
+
const record = team.chains.get(chain.chainId);
|
|
889
|
+
if (record === void 0) return;
|
|
890
|
+
const edge = edgeKey(fromId, recipient.id);
|
|
891
|
+
if ((record.edges.get(edge) ?? 0) >= this.config.maxChainRoundTrips) throw new TeamError("PING_PONG", `${this.config.maxChainRoundTrips} message(s) already went to ${recipient.name} in this conversation`);
|
|
892
|
+
if (record.said.get(edge) === text) throw new TeamError("REPEATED_MESSAGE", recipient.name);
|
|
893
|
+
}
|
|
894
|
+
/** Charge one accepted peer delivery to its chain, and hand the chain on. */
|
|
895
|
+
recordDelivery(team, chain, fromId, recipient, text) {
|
|
896
|
+
if (recipient.kind !== "member") return;
|
|
897
|
+
team.inbox.set(recipient.id, chain);
|
|
898
|
+
let record = team.chains.get(chain.chainId);
|
|
899
|
+
if (record === void 0) {
|
|
900
|
+
record = {
|
|
901
|
+
edges: /* @__PURE__ */ new Map(),
|
|
902
|
+
said: /* @__PURE__ */ new Map()
|
|
903
|
+
};
|
|
904
|
+
team.chains.set(chain.chainId, record);
|
|
905
|
+
for (const stale of team.chains.keys()) {
|
|
906
|
+
if (team.chains.size <= CHAIN_MEMORY) break;
|
|
907
|
+
team.chains.delete(stale);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
const edge = edgeKey(fromId, recipient.id);
|
|
911
|
+
record.edges.set(edge, (record.edges.get(edge) ?? 0) + 1);
|
|
912
|
+
record.said.set(edge, text);
|
|
913
|
+
}
|
|
914
|
+
/** Deliver one message, choosing the transport the recipient's runtime requires. */
|
|
915
|
+
async deliver(actor, recipient, content, signal, chain) {
|
|
916
|
+
const source = {
|
|
917
|
+
kind: "team-message",
|
|
918
|
+
form: "relay",
|
|
919
|
+
senderSessionId: actor.member?.memberId ?? actor.leader.id,
|
|
920
|
+
senderName: actor.member?.name ?? "leader",
|
|
921
|
+
chainId: chain.chainId,
|
|
922
|
+
hop: chain.hop
|
|
923
|
+
};
|
|
924
|
+
if (recipient.kind === "leader") {
|
|
925
|
+
const message = createUserMessage({
|
|
926
|
+
content,
|
|
927
|
+
source
|
|
928
|
+
});
|
|
929
|
+
actor.leader.send(message, "next-turn", true);
|
|
930
|
+
return message.id;
|
|
931
|
+
}
|
|
932
|
+
return await this.ctx.subagents.followup(actor.leader, SessionId(recipient.id), content, {
|
|
933
|
+
source,
|
|
934
|
+
signal
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* Reject a reasoning effort the selected model does not offer, at spawn
|
|
939
|
+
* rather than on the teammate's every later request. Validation needs an
|
|
940
|
+
* exact provider/model route: without one the adapter stays the authority.
|
|
941
|
+
*/
|
|
942
|
+
async assertEffortOffered(leader, request) {
|
|
943
|
+
if (request.reasoningEffort === void 0) return;
|
|
944
|
+
const llm = this.ctx.get("llm");
|
|
945
|
+
const provider = leader.options.provider;
|
|
946
|
+
const model = request.model ?? leader.options.model;
|
|
947
|
+
if (llm === void 0 || provider === void 0 || model === void 0) return;
|
|
948
|
+
const offered = (await llm.resolveModelInfo(provider, model)).reasoning?.efforts ?? [];
|
|
949
|
+
if (offered.some((effort) => effort.id === request.reasoningEffort)) return;
|
|
950
|
+
throw new TeamError("UNKNOWN_EFFORT", offered.length === 0 ? `${model} exposes no reasoning efforts` : `${model} offers ${offered.map((effort) => effort.id).join(", ")}`);
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Stop one teammate's current work. Residency belongs to the continuation
|
|
954
|
+
* manager, so dismissal interrupts rather than disposes: an interrupted
|
|
955
|
+
* teammate settles on its own and its session stays readable.
|
|
956
|
+
*/
|
|
957
|
+
stopMember(leader, memberId) {
|
|
958
|
+
try {
|
|
959
|
+
this.ctx.subagents.interrupt(SessionId(memberId), {
|
|
960
|
+
kind: "ancestor",
|
|
961
|
+
agent: leader
|
|
962
|
+
});
|
|
963
|
+
} catch (error) {
|
|
964
|
+
this.ctx.logger.warn("dsh-team: interrupt of teammate %s failed: %s", memberId, String(error));
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
/** Service teardown: live teams are rebuilt from their logs on next touch. */
|
|
968
|
+
stop() {
|
|
969
|
+
this.teams.clear();
|
|
970
|
+
this.pending.clear();
|
|
971
|
+
}
|
|
972
|
+
};
|
|
973
|
+
/** Strip the live-view fields a roster fact does not carry. */
|
|
974
|
+
function memberFact(member) {
|
|
975
|
+
return {
|
|
976
|
+
memberId: member.memberId,
|
|
977
|
+
name: member.name,
|
|
978
|
+
relation: member.relation,
|
|
979
|
+
...member.role !== void 0 ? { role: member.role } : {},
|
|
980
|
+
...member.model !== void 0 ? { model: member.model } : {},
|
|
981
|
+
...member.effort !== void 0 ? { effort: member.effort } : {}
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
/** One ordered pair, as the chain ledger keys it. */
|
|
985
|
+
function edgeKey(fromId, toId) {
|
|
986
|
+
return `${fromId} ${toId}`;
|
|
987
|
+
}
|
|
988
|
+
/** Case-insensitive roster lookup by display name. */
|
|
989
|
+
function findByName(team, name) {
|
|
990
|
+
const wanted = name.trim().toLowerCase();
|
|
991
|
+
for (const member of team.members.values()) if (member.name.toLowerCase() === wanted) return member;
|
|
992
|
+
}
|
|
993
|
+
/** Short stable task id, unique within one team's live list. */
|
|
994
|
+
function taskIdOf(team) {
|
|
995
|
+
let next = team.tasks.size + 1;
|
|
996
|
+
while (team.tasks.has(`t${next}`)) next += 1;
|
|
997
|
+
return `t${next}`;
|
|
998
|
+
}
|
|
999
|
+
/** The teammate's first turn: who it is, and what the leader asked for. */
|
|
1000
|
+
function brief(request) {
|
|
1001
|
+
return [
|
|
1002
|
+
request.role === void 0 ? `You are ${request.name}, a teammate in this agent team.` : `You are ${request.name} (${request.role}), a teammate in this agent team.`,
|
|
1003
|
+
"",
|
|
1004
|
+
"The leader assigned you this task:",
|
|
1005
|
+
request.task
|
|
1006
|
+
].join("\n");
|
|
1007
|
+
}
|
|
1008
|
+
//#endregion
|
|
1009
|
+
//#region src/workspace.ts
|
|
1010
|
+
/** Keys a member may name: readable, bounded, and free of the record separator. */
|
|
1011
|
+
const KEY_PATTERN = /^[\w .\-一-鿿]{1,64}$/u;
|
|
1012
|
+
/** Longest preview one board entry contributes to the leader's projection. */
|
|
1013
|
+
const PREVIEW_CHARS = 180;
|
|
1014
|
+
/** The shared area's stable id; every other area id is a member's session id. */
|
|
1015
|
+
const SHARED_AREA = "shared";
|
|
1016
|
+
/** One stored note, whole: the record IS the entity, never a delta. */
|
|
1017
|
+
const entrySchema = z$1.object({
|
|
1018
|
+
/** The leader session whose team owns this workspace. */
|
|
1019
|
+
leaderId: z$1.string(),
|
|
1020
|
+
/** `shared`, or the session id of the member whose private pad this is. */
|
|
1021
|
+
area: z$1.string(),
|
|
1022
|
+
key: z$1.string(),
|
|
1023
|
+
text: z$1.string(),
|
|
1024
|
+
authorId: z$1.string(),
|
|
1025
|
+
authorName: z$1.string(),
|
|
1026
|
+
updatedAt: z$1.number()
|
|
1027
|
+
});
|
|
1028
|
+
/** The domain this plugin owns: one table of notes across every team. */
|
|
1029
|
+
const WORKSPACE_DOMAIN = defineDomain({
|
|
1030
|
+
name: "team_workspace",
|
|
1031
|
+
version: 1,
|
|
1032
|
+
tables: { entries: domainTable(entrySchema) }
|
|
1033
|
+
});
|
|
1034
|
+
/** Record address: leader, area, and key, in one line-separated string. */
|
|
1035
|
+
function recordKey(leaderId, area, key) {
|
|
1036
|
+
return `${leaderId}\n${area}\n${key}`;
|
|
1037
|
+
}
|
|
1038
|
+
/** Reject a key the model invented that would not survive as an address. */
|
|
1039
|
+
function assertKey(key) {
|
|
1040
|
+
const trimmed = key.trim();
|
|
1041
|
+
if (!KEY_PATTERN.test(trimmed)) throw new TeamError("INVALID_NOTE_KEY", "use up to 64 letters, digits, spaces, dots, or dashes");
|
|
1042
|
+
return trimmed;
|
|
1043
|
+
}
|
|
1044
|
+
/** The one-line preview the leader's projection carries for one entry. */
|
|
1045
|
+
function previewOf(text) {
|
|
1046
|
+
const line = text.split("\n").find((part) => part.trim().length > 0)?.trim() ?? "";
|
|
1047
|
+
return line.length > PREVIEW_CHARS ? `${line.slice(0, PREVIEW_CHARS)}…` : line;
|
|
1048
|
+
}
|
|
1049
|
+
/** Newest first: a workspace is read from the top. */
|
|
1050
|
+
function byNewest(left, right) {
|
|
1051
|
+
return right.updatedAt - left.updatedAt;
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* The team workspaces over one open storage domain. One instance serves every
|
|
1055
|
+
* team in the process; records carry their own leader and area, so a read is a
|
|
1056
|
+
* filter and no two teams can see each other's notes.
|
|
1057
|
+
*/
|
|
1058
|
+
var TeamWorkspace = class {
|
|
1059
|
+
config;
|
|
1060
|
+
opening;
|
|
1061
|
+
disposed = false;
|
|
1062
|
+
/**
|
|
1063
|
+
* @param ctx - a context whose `storageDomain` is already resolved.
|
|
1064
|
+
* @param config - the row config carrying the workspace bounds.
|
|
1065
|
+
*/
|
|
1066
|
+
constructor(ctx, config) {
|
|
1067
|
+
this.config = config;
|
|
1068
|
+
this.opening = ctx.storageDomain.open(WORKSPACE_DOMAIN);
|
|
1069
|
+
this.opening.then((domain) => {
|
|
1070
|
+
if (this.disposed) domain.close();
|
|
1071
|
+
}, () => void 0);
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Read one area of one team's workspace, newest first.
|
|
1075
|
+
* @param leaderId - the team's leader session id.
|
|
1076
|
+
* @param area - {@link SHARED_AREA} or a member's session id.
|
|
1077
|
+
* @returns the notes in that area.
|
|
1078
|
+
*/
|
|
1079
|
+
async read(leaderId, area) {
|
|
1080
|
+
const table = (await this.opening).table("entries");
|
|
1081
|
+
const found = [];
|
|
1082
|
+
for (const [, entry] of table.entries()) if (entry.leaderId === leaderId && entry.area === area) found.push(entry);
|
|
1083
|
+
return found.sort(byNewest);
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Write one note, replacing whatever the key held.
|
|
1087
|
+
* @param leaderId - the team's leader session id.
|
|
1088
|
+
* @param area - {@link SHARED_AREA} or the author's own session id.
|
|
1089
|
+
* @param key - the note's name, as the model gave it.
|
|
1090
|
+
* @param text - the whole note body.
|
|
1091
|
+
* @param author - who is writing.
|
|
1092
|
+
* @param now - epoch ms recorded on the note.
|
|
1093
|
+
* @returns the stored note.
|
|
1094
|
+
* @throws {TeamError} on an unusable key, an oversized note, or a full area.
|
|
1095
|
+
*/
|
|
1096
|
+
async write(leaderId, area, key, text, author, now) {
|
|
1097
|
+
const name = assertKey(key);
|
|
1098
|
+
if (text.length > this.config.maxNoteChars) throw new TeamError("NOTE_TOO_LONG", `${text.length} of at most ${this.config.maxNoteChars} characters`);
|
|
1099
|
+
const table = (await this.opening).table("entries");
|
|
1100
|
+
const address = recordKey(leaderId, area, name);
|
|
1101
|
+
if (table.get(address) === void 0) {
|
|
1102
|
+
if ((await this.read(leaderId, area)).length >= this.config.maxWorkspaceEntries) throw new TeamError("WORKSPACE_FULL", `${this.config.maxWorkspaceEntries} notes already — replace or drop one before adding another`);
|
|
1103
|
+
}
|
|
1104
|
+
const entry = {
|
|
1105
|
+
leaderId,
|
|
1106
|
+
area,
|
|
1107
|
+
key: name,
|
|
1108
|
+
text,
|
|
1109
|
+
authorId: author.id,
|
|
1110
|
+
authorName: author.name,
|
|
1111
|
+
updatedAt: now
|
|
1112
|
+
};
|
|
1113
|
+
await table.put(address, entry);
|
|
1114
|
+
return entry;
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Drop one note.
|
|
1118
|
+
* @param leaderId - the team's leader session id.
|
|
1119
|
+
* @param area - the area holding it.
|
|
1120
|
+
* @param key - the note's name.
|
|
1121
|
+
* @throws {TeamError} when no note of that name is in the area.
|
|
1122
|
+
*/
|
|
1123
|
+
async remove(leaderId, area, key) {
|
|
1124
|
+
const name = assertKey(key);
|
|
1125
|
+
if (!await (await this.opening).table("entries").delete(recordKey(leaderId, area, name))) throw new TeamError("UNKNOWN_NOTE", name);
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Drop everything one area holds — a dismissed member's private pad, or a
|
|
1129
|
+
* disbanded team's whole workspace.
|
|
1130
|
+
* @param leaderId - the team's leader session id.
|
|
1131
|
+
* @param area - one area, or undefined for every area of this team.
|
|
1132
|
+
*/
|
|
1133
|
+
async clear(leaderId, area) {
|
|
1134
|
+
const table = (await this.opening).table("entries");
|
|
1135
|
+
const doomed = [];
|
|
1136
|
+
for (const [address, entry] of table.entries()) if (entry.leaderId === leaderId && (area === void 0 || entry.area === area)) doomed.push(address);
|
|
1137
|
+
for (const address of doomed) await table.delete(address);
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* The shared area as the leader's durable projection carries it: names,
|
|
1141
|
+
* attribution, and a one-line preview, never whole note bodies. A private
|
|
1142
|
+
* pad is never projected — private means private, including from the panel.
|
|
1143
|
+
* @param leaderId - the team's leader session id.
|
|
1144
|
+
* @returns the board index, newest first.
|
|
1145
|
+
*/
|
|
1146
|
+
async index(leaderId) {
|
|
1147
|
+
return (await this.read(leaderId, SHARED_AREA)).map((entry) => ({
|
|
1148
|
+
key: entry.key,
|
|
1149
|
+
authorId: entry.authorId,
|
|
1150
|
+
authorName: entry.authorName,
|
|
1151
|
+
updatedAt: entry.updatedAt,
|
|
1152
|
+
preview: previewOf(entry.text)
|
|
1153
|
+
}));
|
|
1154
|
+
}
|
|
1155
|
+
/** Release the domain handle; queued writes drain first. */
|
|
1156
|
+
dispose() {
|
|
1157
|
+
this.disposed = true;
|
|
1158
|
+
this.opening.then((domain) => domain.close(), () => void 0);
|
|
1159
|
+
}
|
|
1160
|
+
};
|
|
1161
|
+
//#endregion
|
|
1162
|
+
//#region src/tools.ts
|
|
1163
|
+
/** The acting agent; a team tool without one is a composition mistake. */
|
|
1164
|
+
function actor(agent) {
|
|
1165
|
+
if (agent === void 0) throw new Error("team tools require an acting agent");
|
|
1166
|
+
return agent;
|
|
1167
|
+
}
|
|
1168
|
+
/** Pending-call card with the team treatment. */
|
|
1169
|
+
function call(title, rawInput) {
|
|
1170
|
+
return {
|
|
1171
|
+
card: "generic",
|
|
1172
|
+
title,
|
|
1173
|
+
kind: "other",
|
|
1174
|
+
...rawInput !== void 0 ? { rawInput } : {}
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
/** Completed-call card carrying one line of prose. */
|
|
1178
|
+
function done(title, text) {
|
|
1179
|
+
return {
|
|
1180
|
+
card: "generic",
|
|
1181
|
+
title,
|
|
1182
|
+
...text !== void 0 ? { content: [{
|
|
1183
|
+
type: "text",
|
|
1184
|
+
text
|
|
1185
|
+
}] } : {}
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
/** First text block of a failed result, for the failure card. */
|
|
1189
|
+
function failureText(result) {
|
|
1190
|
+
const first = result.content[0];
|
|
1191
|
+
return first !== null && typeof first === "object" && first.type === "text" ? String(first.text ?? "failed") : "failed";
|
|
1192
|
+
}
|
|
1193
|
+
/** Member facts as one output value and one durable fact share them. */
|
|
1194
|
+
const MEMBER_PROPERTIES = {
|
|
1195
|
+
memberId: {
|
|
1196
|
+
type: "string",
|
|
1197
|
+
required: true
|
|
1198
|
+
},
|
|
1199
|
+
name: {
|
|
1200
|
+
type: "string",
|
|
1201
|
+
required: true
|
|
1202
|
+
},
|
|
1203
|
+
role: { type: "string" },
|
|
1204
|
+
relation: {
|
|
1205
|
+
type: "string",
|
|
1206
|
+
required: true,
|
|
1207
|
+
enum: ["managed", "peer"]
|
|
1208
|
+
},
|
|
1209
|
+
model: { type: "string" },
|
|
1210
|
+
effort: { type: "string" }
|
|
1211
|
+
};
|
|
1212
|
+
/** Task facts as one output value and one durable fact share them. */
|
|
1213
|
+
const TASK_PROPERTIES = {
|
|
1214
|
+
taskId: {
|
|
1215
|
+
type: "string",
|
|
1216
|
+
required: true
|
|
1217
|
+
},
|
|
1218
|
+
title: {
|
|
1219
|
+
type: "string",
|
|
1220
|
+
required: true
|
|
1221
|
+
},
|
|
1222
|
+
assigneeId: { type: "string" },
|
|
1223
|
+
status: {
|
|
1224
|
+
type: "string",
|
|
1225
|
+
required: true,
|
|
1226
|
+
enum: [
|
|
1227
|
+
"pending",
|
|
1228
|
+
"active",
|
|
1229
|
+
"done"
|
|
1230
|
+
]
|
|
1231
|
+
},
|
|
1232
|
+
note: { type: "string" }
|
|
1233
|
+
};
|
|
1234
|
+
/** Drop the undefined optional members a JSON fact must not carry. */
|
|
1235
|
+
function memberValue(member) {
|
|
1236
|
+
return {
|
|
1237
|
+
memberId: member.memberId,
|
|
1238
|
+
name: member.name,
|
|
1239
|
+
relation: member.relation,
|
|
1240
|
+
...member.role !== void 0 ? { role: member.role } : {},
|
|
1241
|
+
...member.model !== void 0 ? { model: member.model } : {},
|
|
1242
|
+
...member.effort !== void 0 ? { effort: member.effort } : {}
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
/**
|
|
1246
|
+
* `team_spawn` — start a teammate. Leader-only: a teammate that could spawn
|
|
1247
|
+
* would own a team its leader cannot see.
|
|
1248
|
+
* @param ctx - context carrying the team service.
|
|
1249
|
+
* @returns the tool definition.
|
|
1250
|
+
*/
|
|
1251
|
+
function spawnTool(ctx) {
|
|
1252
|
+
return defineTool({
|
|
1253
|
+
name: "team_spawn",
|
|
1254
|
+
description: "Add a teammate to your agent team. A teammate is a long-lived agent with its own session, its own memory and its own tools; it works in the background while you keep working, and it stays available until you dismiss it. Give it a name you will address it by and a first task. relation \"managed\" means it may only message you; \"peer\" means it may also message the other teammates directly and coordinate with them without going through you. Prefer a teammate over a one-shot subagent when the work needs several rounds, a durable owner, or someone the rest of the team can talk to.",
|
|
1255
|
+
parameters: {
|
|
1256
|
+
name: {
|
|
1257
|
+
type: "string",
|
|
1258
|
+
required: true,
|
|
1259
|
+
description: "Short display name you and the team will address it by, e.g. Alice."
|
|
1260
|
+
},
|
|
1261
|
+
task: {
|
|
1262
|
+
type: "string",
|
|
1263
|
+
required: true,
|
|
1264
|
+
description: "The first task, self-contained: the teammate does not see your conversation."
|
|
1265
|
+
},
|
|
1266
|
+
relation: {
|
|
1267
|
+
type: "string",
|
|
1268
|
+
required: true,
|
|
1269
|
+
enum: ["managed", "peer"],
|
|
1270
|
+
description: "managed: reports only to you. peer: may also message other teammates directly."
|
|
1271
|
+
},
|
|
1272
|
+
role: {
|
|
1273
|
+
type: "string",
|
|
1274
|
+
description: "Optional role, e.g. reviewer. Shown to the whole team."
|
|
1275
|
+
},
|
|
1276
|
+
persona: {
|
|
1277
|
+
type: "string",
|
|
1278
|
+
description: "Optional persona replacing the deployment persona for this teammate only."
|
|
1279
|
+
},
|
|
1280
|
+
model: {
|
|
1281
|
+
type: "string",
|
|
1282
|
+
description: "Optional model id; default inherits your own model."
|
|
1283
|
+
},
|
|
1284
|
+
reasoning_effort: {
|
|
1285
|
+
type: "string",
|
|
1286
|
+
description: "Optional provider-owned reasoning effort for this teammate, e.g. high. Rejected when the model does not offer it."
|
|
1287
|
+
}
|
|
1288
|
+
},
|
|
1289
|
+
output: {
|
|
1290
|
+
schema: {
|
|
1291
|
+
type: "object",
|
|
1292
|
+
additionalProperties: false,
|
|
1293
|
+
properties: MEMBER_PROPERTIES
|
|
1294
|
+
},
|
|
1295
|
+
render: (_args, value) => [{
|
|
1296
|
+
type: "text",
|
|
1297
|
+
text: `teammate ${value.name} joined as a ${value.relation} member and started on its task. Address it as "${value.name}" or "${value.memberId}".`
|
|
1298
|
+
}],
|
|
1299
|
+
presentationMeta: (_args, value) => ({
|
|
1300
|
+
team: "member-added",
|
|
1301
|
+
member: value
|
|
1302
|
+
})
|
|
1303
|
+
},
|
|
1304
|
+
presentCall: (args) => call(`Spawn teammate ${args.name}`, {
|
|
1305
|
+
relation: args.relation,
|
|
1306
|
+
...args.role !== void 0 ? { role: args.role } : {},
|
|
1307
|
+
...args.model !== void 0 ? { model: args.model } : {},
|
|
1308
|
+
task: args.task
|
|
1309
|
+
}),
|
|
1310
|
+
presentResult: (args, result) => result.isError ? done(`Spawn teammate ${args.name}`, `failed: ${failureText(result)}`) : done(`${args.name} joined the team`, args.role === void 0 ? args.relation : `${args.role} · ${args.relation}`),
|
|
1311
|
+
isConcurrencySafe: () => false,
|
|
1312
|
+
async execute(args, exec) {
|
|
1313
|
+
return memberValue(await ctx.team.spawn(actor(exec.agent), {
|
|
1314
|
+
name: args.name,
|
|
1315
|
+
task: args.task,
|
|
1316
|
+
relation: args.relation,
|
|
1317
|
+
...args.role !== void 0 ? { role: args.role } : {},
|
|
1318
|
+
...args.persona !== void 0 ? { persona: args.persona } : {},
|
|
1319
|
+
...args.model !== void 0 ? { model: args.model } : {},
|
|
1320
|
+
...args.reasoning_effort !== void 0 ? { reasoningEffort: args.reasoning_effort } : {}
|
|
1321
|
+
}, exec.signal));
|
|
1322
|
+
}
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* `team_send` — the mailbox, shared by the leader and every teammate. The
|
|
1327
|
+
* service decides what the caller's relation allows.
|
|
1328
|
+
* @param ctx - context carrying the team service.
|
|
1329
|
+
* @param audience - whose description this registration serves.
|
|
1330
|
+
* @returns the tool definition.
|
|
1331
|
+
*/
|
|
1332
|
+
function sendTool(ctx, audience) {
|
|
1333
|
+
return defineTool({
|
|
1334
|
+
name: "team_send",
|
|
1335
|
+
description: audience === "leader" ? "Send a message to one teammate. It becomes that teammate's next turn: if it is busy, the message waits until the current turn ends, so it cannot redirect work already underway. Delivery is asynchronous — this returns once the message is accepted, never the teammate's answer; the reply arrives later as its own message to you." : "Send a message to another team member. Address the leader as \"leader\", or a teammate by its name. The message becomes the recipient's next turn; you get no answer back from this call. Use it to ask a peer for input, hand work over, or raise something with the leader mid-task. Finished work goes to the leader through the report tool instead. A conversation between teammates carries a budget: it may only relay so far and you may not keep going back and forth with the same member about it, so ask for what you actually need in one message. Messaging the leader is never refused — when a peer exchange stops converging, that is the way out.",
|
|
1336
|
+
parameters: {
|
|
1337
|
+
to: {
|
|
1338
|
+
type: "string",
|
|
1339
|
+
required: true,
|
|
1340
|
+
description: "Recipient: a teammate name, a member id, or \"leader\"."
|
|
1341
|
+
},
|
|
1342
|
+
message: {
|
|
1343
|
+
type: "string",
|
|
1344
|
+
required: true,
|
|
1345
|
+
description: "Self-contained message; the recipient does not see your conversation."
|
|
1346
|
+
}
|
|
1347
|
+
},
|
|
1348
|
+
output: {
|
|
1349
|
+
schema: {
|
|
1350
|
+
type: "object",
|
|
1351
|
+
additionalProperties: false,
|
|
1352
|
+
properties: {
|
|
1353
|
+
messageId: {
|
|
1354
|
+
type: "string",
|
|
1355
|
+
required: true
|
|
1356
|
+
},
|
|
1357
|
+
to: {
|
|
1358
|
+
type: "string",
|
|
1359
|
+
required: true
|
|
1360
|
+
},
|
|
1361
|
+
name: {
|
|
1362
|
+
type: "string",
|
|
1363
|
+
required: true
|
|
1364
|
+
},
|
|
1365
|
+
hop: {
|
|
1366
|
+
type: "number",
|
|
1367
|
+
required: true
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
},
|
|
1371
|
+
render: (_args, value) => [{
|
|
1372
|
+
type: "text",
|
|
1373
|
+
text: `message queued as the next turn of ${value.name}`
|
|
1374
|
+
}],
|
|
1375
|
+
presentationMeta: (args, value) => ({
|
|
1376
|
+
team: "message",
|
|
1377
|
+
messageId: value.messageId,
|
|
1378
|
+
to: value.to,
|
|
1379
|
+
text: args.message,
|
|
1380
|
+
hop: value.hop
|
|
1381
|
+
})
|
|
1382
|
+
},
|
|
1383
|
+
presentCall: (args) => call(`Message ${args.to}`, args.message),
|
|
1384
|
+
presentResult: (args, result) => result.isError ? done(`Message ${args.to}`, `not delivered: ${failureText(result)}`) : done(`Message sent to ${args.to}`),
|
|
1385
|
+
async execute(args, exec) {
|
|
1386
|
+
const sent = await ctx.team.send(actor(exec.agent), args.to, args.message, exec.signal);
|
|
1387
|
+
return {
|
|
1388
|
+
messageId: sent.messageId,
|
|
1389
|
+
to: sent.recipient.id,
|
|
1390
|
+
name: sent.recipient.name,
|
|
1391
|
+
hop: sent.chain.hop
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* `team_task` — the shared task list. Writes are the leader's; teammates read
|
|
1398
|
+
* it through `team_list` and report their outcomes through the built-in
|
|
1399
|
+
* `report` tool.
|
|
1400
|
+
* @param ctx - context carrying the team service.
|
|
1401
|
+
* @returns the tool definition.
|
|
1402
|
+
*/
|
|
1403
|
+
function taskTool(ctx) {
|
|
1404
|
+
return defineTool({
|
|
1405
|
+
name: "team_task",
|
|
1406
|
+
description: "Create or update one row of the shared team task list — the list every teammate can read, so it is where multi-teammate work is coordinated without routing every detail through messages. Omit task_id to create a row (title required); pass task_id to update one. Assign with a teammate name or member id. A teammate closes its own row through its report, so you rarely set status yourself.",
|
|
1407
|
+
parameters: {
|
|
1408
|
+
title: {
|
|
1409
|
+
type: "string",
|
|
1410
|
+
description: "Task title; required when creating."
|
|
1411
|
+
},
|
|
1412
|
+
assignee: {
|
|
1413
|
+
type: "string",
|
|
1414
|
+
description: "Teammate name or member id; omit to leave the task unassigned."
|
|
1415
|
+
},
|
|
1416
|
+
task_id: {
|
|
1417
|
+
type: "string",
|
|
1418
|
+
description: "Existing task id to update; omit to create a new task."
|
|
1419
|
+
},
|
|
1420
|
+
status: {
|
|
1421
|
+
type: "string",
|
|
1422
|
+
enum: [
|
|
1423
|
+
"pending",
|
|
1424
|
+
"active",
|
|
1425
|
+
"done"
|
|
1426
|
+
],
|
|
1427
|
+
description: "Task state; defaults to pending on creation."
|
|
1428
|
+
},
|
|
1429
|
+
note: {
|
|
1430
|
+
type: "string",
|
|
1431
|
+
description: "Short note recorded with the task, e.g. why it was closed."
|
|
1432
|
+
}
|
|
1433
|
+
},
|
|
1434
|
+
output: {
|
|
1435
|
+
schema: {
|
|
1436
|
+
type: "object",
|
|
1437
|
+
additionalProperties: false,
|
|
1438
|
+
properties: TASK_PROPERTIES
|
|
1439
|
+
},
|
|
1440
|
+
render: (_args, value) => [{
|
|
1441
|
+
type: "text",
|
|
1442
|
+
text: `task ${value.taskId} "${value.title}" is ${value.status}` + (value.assigneeId === void 0 ? " and unassigned" : ` for ${value.assigneeId}`)
|
|
1443
|
+
}],
|
|
1444
|
+
presentationMeta: (_args, value) => ({
|
|
1445
|
+
team: "task",
|
|
1446
|
+
task: value
|
|
1447
|
+
})
|
|
1448
|
+
},
|
|
1449
|
+
presentCall: (args) => call(args.task_id === void 0 ? `New task: ${args.title ?? ""}` : `Update task ${args.task_id}`, {
|
|
1450
|
+
...args.assignee !== void 0 ? { assignee: args.assignee } : {},
|
|
1451
|
+
...args.status !== void 0 ? { status: args.status } : {}
|
|
1452
|
+
}),
|
|
1453
|
+
presentResult: (args, result) => result.isError ? done("Team task", `failed: ${failureText(result)}`) : done(args.task_id === void 0 ? `Task added: ${args.title ?? ""}` : `Task ${args.task_id} updated`),
|
|
1454
|
+
isConcurrencySafe: () => false,
|
|
1455
|
+
execute(args, exec) {
|
|
1456
|
+
const task = ctx.team.upsertTask(actor(exec.agent), {
|
|
1457
|
+
...args.task_id !== void 0 ? { taskId: args.task_id } : {},
|
|
1458
|
+
...args.title !== void 0 ? { title: args.title } : {},
|
|
1459
|
+
...args.assignee !== void 0 ? { assigneeId: args.assignee } : {},
|
|
1460
|
+
...args.status !== void 0 ? { status: args.status } : {},
|
|
1461
|
+
...args.note !== void 0 ? { note: args.note } : {}
|
|
1462
|
+
});
|
|
1463
|
+
return Promise.resolve({
|
|
1464
|
+
taskId: task.taskId,
|
|
1465
|
+
title: task.title,
|
|
1466
|
+
status: task.status,
|
|
1467
|
+
...task.assigneeId !== void 0 ? { assigneeId: task.assigneeId } : {},
|
|
1468
|
+
...task.note !== void 0 ? { note: task.note } : {}
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* `team_relation` — widen or tighten one teammate's autonomy.
|
|
1475
|
+
* @param ctx - context carrying the team service.
|
|
1476
|
+
* @returns the tool definition.
|
|
1477
|
+
*/
|
|
1478
|
+
function relationTool(ctx) {
|
|
1479
|
+
return defineTool({
|
|
1480
|
+
name: "team_relation",
|
|
1481
|
+
description: "Change how much one teammate may talk to the rest of the team. \"peer\" lets it message other teammates directly and self-coordinate; \"managed\" routes all of its traffic back through you. Widen when a teammate needs to work with another one; tighten when you want every hand-off to pass your desk.",
|
|
1482
|
+
parameters: {
|
|
1483
|
+
member: {
|
|
1484
|
+
type: "string",
|
|
1485
|
+
required: true,
|
|
1486
|
+
description: "Teammate name or member id."
|
|
1487
|
+
},
|
|
1488
|
+
relation: {
|
|
1489
|
+
type: "string",
|
|
1490
|
+
required: true,
|
|
1491
|
+
enum: ["managed", "peer"],
|
|
1492
|
+
description: "The new relation."
|
|
1493
|
+
}
|
|
1494
|
+
},
|
|
1495
|
+
output: {
|
|
1496
|
+
schema: {
|
|
1497
|
+
type: "object",
|
|
1498
|
+
additionalProperties: false,
|
|
1499
|
+
properties: MEMBER_PROPERTIES
|
|
1500
|
+
},
|
|
1501
|
+
render: (_args, value) => [{
|
|
1502
|
+
type: "text",
|
|
1503
|
+
text: `${value.name} is now a ${value.relation} member`
|
|
1504
|
+
}],
|
|
1505
|
+
presentationMeta: (_args, value) => ({
|
|
1506
|
+
team: "member-updated",
|
|
1507
|
+
member: value
|
|
1508
|
+
})
|
|
1509
|
+
},
|
|
1510
|
+
presentCall: (args) => call(`Set ${args.member} to ${args.relation}`),
|
|
1511
|
+
presentResult: (args, result) => result.isError ? done(`Set ${args.member} to ${args.relation}`, `failed: ${failureText(result)}`) : done(`${args.member} is now ${args.relation}`),
|
|
1512
|
+
isConcurrencySafe: () => false,
|
|
1513
|
+
execute(args, exec) {
|
|
1514
|
+
return Promise.resolve(memberValue(ctx.team.setRelation(actor(exec.agent), args.member, args.relation)));
|
|
1515
|
+
}
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
/**
|
|
1519
|
+
* `team_dismiss` — release one teammate, or the whole team.
|
|
1520
|
+
* @param ctx - context carrying the team service.
|
|
1521
|
+
* @returns the tool definition.
|
|
1522
|
+
*/
|
|
1523
|
+
function dismissTool(ctx) {
|
|
1524
|
+
return defineTool({
|
|
1525
|
+
name: "team_dismiss",
|
|
1526
|
+
description: "Dismiss one teammate, or the whole team when you name nobody. A dismissed teammate stops what it is doing and receives no further messages; its transcript stays readable. Dismiss teammates whose work is finished — an idle teammate costs nothing to keep, but a stale one invites you to message it again.",
|
|
1527
|
+
parameters: { member: {
|
|
1528
|
+
type: "string",
|
|
1529
|
+
description: "Teammate name or member id; omit to dismiss the whole team."
|
|
1530
|
+
} },
|
|
1531
|
+
output: {
|
|
1532
|
+
schema: {
|
|
1533
|
+
type: "object",
|
|
1534
|
+
additionalProperties: false,
|
|
1535
|
+
properties: {
|
|
1536
|
+
ended: {
|
|
1537
|
+
type: "boolean",
|
|
1538
|
+
required: true
|
|
1539
|
+
},
|
|
1540
|
+
memberId: { type: "string" }
|
|
1541
|
+
}
|
|
1542
|
+
},
|
|
1543
|
+
render: (_args, value) => [{
|
|
1544
|
+
type: "text",
|
|
1545
|
+
text: value.ended ? "the team is disbanded" : `teammate ${String(value.memberId)} is dismissed`
|
|
1546
|
+
}],
|
|
1547
|
+
presentationMeta: (_args, value) => value.ended ? { team: "ended" } : {
|
|
1548
|
+
team: "member-removed",
|
|
1549
|
+
memberId: String(value.memberId)
|
|
1550
|
+
}
|
|
1551
|
+
},
|
|
1552
|
+
presentCall: (args) => call(args.member === void 0 ? "Disband the team" : `Dismiss ${args.member}`),
|
|
1553
|
+
presentResult: (args, result) => result.isError ? done("Dismiss", `failed: ${failureText(result)}`) : done(args.member === void 0 ? "Team disbanded" : `${args.member} dismissed`),
|
|
1554
|
+
isConcurrencySafe: () => false,
|
|
1555
|
+
execute(args, exec) {
|
|
1556
|
+
return Promise.resolve(ctx.team.dismiss(actor(exec.agent), args.member));
|
|
1557
|
+
}
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
/**
|
|
1561
|
+
* `team_list` — the shared read every member uses to decide who to talk to.
|
|
1562
|
+
* @param ctx - context carrying the team service.
|
|
1563
|
+
* @param audience - whose reading of an empty team this registration serves.
|
|
1564
|
+
* @returns the tool definition.
|
|
1565
|
+
*/
|
|
1566
|
+
function listTool(ctx, audience = "leader") {
|
|
1567
|
+
return defineTool({
|
|
1568
|
+
name: "team_list",
|
|
1569
|
+
description: "Read the team: every member with its role, relation and live state (running, idle, or ready to wake), the shared task list, and the recent mailbox traffic the leader can see. Use it before messaging or assigning work, and to check whether a teammate is still busy.",
|
|
1570
|
+
parameters: {},
|
|
1571
|
+
output: {
|
|
1572
|
+
schema: {
|
|
1573
|
+
type: "object",
|
|
1574
|
+
additionalProperties: false,
|
|
1575
|
+
properties: {
|
|
1576
|
+
active: {
|
|
1577
|
+
type: "boolean",
|
|
1578
|
+
required: true
|
|
1579
|
+
},
|
|
1580
|
+
members: {
|
|
1581
|
+
type: "array",
|
|
1582
|
+
required: true,
|
|
1583
|
+
items: {
|
|
1584
|
+
type: "object",
|
|
1585
|
+
additionalProperties: false,
|
|
1586
|
+
properties: {
|
|
1587
|
+
...MEMBER_PROPERTIES,
|
|
1588
|
+
status: {
|
|
1589
|
+
type: "string",
|
|
1590
|
+
required: true,
|
|
1591
|
+
enum: [
|
|
1592
|
+
"running",
|
|
1593
|
+
"idle",
|
|
1594
|
+
"ready"
|
|
1595
|
+
]
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
},
|
|
1600
|
+
tasks: {
|
|
1601
|
+
type: "array",
|
|
1602
|
+
required: true,
|
|
1603
|
+
items: {
|
|
1604
|
+
type: "object",
|
|
1605
|
+
additionalProperties: false,
|
|
1606
|
+
properties: TASK_PROPERTIES
|
|
1607
|
+
}
|
|
1608
|
+
},
|
|
1609
|
+
messages: {
|
|
1610
|
+
type: "array",
|
|
1611
|
+
required: true,
|
|
1612
|
+
items: {
|
|
1613
|
+
type: "object",
|
|
1614
|
+
additionalProperties: false,
|
|
1615
|
+
properties: {
|
|
1616
|
+
from: {
|
|
1617
|
+
type: "string",
|
|
1618
|
+
required: true
|
|
1619
|
+
},
|
|
1620
|
+
to: {
|
|
1621
|
+
type: "string",
|
|
1622
|
+
required: true
|
|
1623
|
+
},
|
|
1624
|
+
text: {
|
|
1625
|
+
type: "string",
|
|
1626
|
+
required: true
|
|
1627
|
+
},
|
|
1628
|
+
hop: { type: "number" }
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
},
|
|
1634
|
+
render: (_args, value) => [{
|
|
1635
|
+
type: "text",
|
|
1636
|
+
text: value.active ? `${value.members.length} teammate(s), ${value.tasks.filter((task) => task.status !== "done").length} open task(s)` : audience === "leader" ? "no team yet — team_spawn starts one" : "the team is not readable from here right now; its main session is not loaded"
|
|
1637
|
+
}]
|
|
1638
|
+
},
|
|
1639
|
+
presentCall: () => ({
|
|
1640
|
+
card: "generic",
|
|
1641
|
+
title: "Read the team",
|
|
1642
|
+
kind: "read"
|
|
1643
|
+
}),
|
|
1644
|
+
presentResult: (_args, result) => result.isError ? done("Read the team", "failed") : done("Team state"),
|
|
1645
|
+
execute(_args, exec) {
|
|
1646
|
+
const team = ctx.team.list(actor(exec.agent));
|
|
1647
|
+
return Promise.resolve({
|
|
1648
|
+
active: team.active,
|
|
1649
|
+
members: team.members.map((member) => ({
|
|
1650
|
+
...memberValue(member),
|
|
1651
|
+
status: member.status
|
|
1652
|
+
})),
|
|
1653
|
+
tasks: team.tasks.map((task) => ({
|
|
1654
|
+
taskId: task.taskId,
|
|
1655
|
+
title: task.title,
|
|
1656
|
+
status: task.status,
|
|
1657
|
+
...task.assigneeId !== void 0 ? { assigneeId: task.assigneeId } : {},
|
|
1658
|
+
...task.note !== void 0 ? { note: task.note } : {}
|
|
1659
|
+
})),
|
|
1660
|
+
messages: team.messages.map((message) => ({
|
|
1661
|
+
from: message.from ?? "leader",
|
|
1662
|
+
to: message.to ?? "leader",
|
|
1663
|
+
text: message.text,
|
|
1664
|
+
...message.hop !== void 0 ? { hop: message.hop } : {}
|
|
1665
|
+
}))
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
/** The notes one `team_board` read returns for a named key. */
|
|
1671
|
+
function pickNote(held, key) {
|
|
1672
|
+
const wanted = key.trim();
|
|
1673
|
+
return held.filter((entry) => entry.key === wanted);
|
|
1674
|
+
}
|
|
1675
|
+
/** Board entry facts as one output value and one durable fact share them. */
|
|
1676
|
+
const BOARD_PROPERTIES = {
|
|
1677
|
+
key: {
|
|
1678
|
+
type: "string",
|
|
1679
|
+
required: true
|
|
1680
|
+
},
|
|
1681
|
+
authorId: {
|
|
1682
|
+
type: "string",
|
|
1683
|
+
required: true
|
|
1684
|
+
},
|
|
1685
|
+
authorName: {
|
|
1686
|
+
type: "string",
|
|
1687
|
+
required: true
|
|
1688
|
+
},
|
|
1689
|
+
updatedAt: {
|
|
1690
|
+
type: "number",
|
|
1691
|
+
required: true
|
|
1692
|
+
},
|
|
1693
|
+
preview: {
|
|
1694
|
+
type: "string",
|
|
1695
|
+
required: true
|
|
1696
|
+
}
|
|
1697
|
+
};
|
|
1698
|
+
/** The team's workspace, the area, and the signature one call addresses. */
|
|
1699
|
+
function place(seat, priv) {
|
|
1700
|
+
return {
|
|
1701
|
+
leaderId: seat.leaderId,
|
|
1702
|
+
area: priv ? seat.memberId : SHARED_AREA,
|
|
1703
|
+
author: {
|
|
1704
|
+
id: seat.memberId,
|
|
1705
|
+
name: seat.name
|
|
1706
|
+
}
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
/**
|
|
1710
|
+
* `team_note` — write or drop one note in a virtual workspace.
|
|
1711
|
+
* @param workspace - the open workspace domain.
|
|
1712
|
+
* @param audience - whose description this registration serves.
|
|
1713
|
+
* @param seatOf - where the caller sits (see {@link SeatResolver}).
|
|
1714
|
+
* @returns the tool definition.
|
|
1715
|
+
*/
|
|
1716
|
+
function noteTool(workspace, audience, seatOf) {
|
|
1717
|
+
return defineTool({
|
|
1718
|
+
name: "team_note",
|
|
1719
|
+
description: "Write one note into a team workspace. These workspaces are the team's own — they are NOT files and they are not in the user's working tree. " + (audience === "leader" ? "Every teammate reads and writes the shared board, so it is where a decision belongs once you have made it — leaving a note costs no turn, while messaging someone costs one of theirs." : "Every member reads and writes the shared board. Put a conclusion there instead of messaging it around: a note costs nobody a turn, and it is still there after you have finished and gone idle.") + " With private=true the note goes to your own pad instead, which nobody else can read: use it to keep your own state across turns. Writing a key that already exists replaces it whole; omit text to drop the note.",
|
|
1720
|
+
parameters: {
|
|
1721
|
+
key: {
|
|
1722
|
+
type: "string",
|
|
1723
|
+
required: true,
|
|
1724
|
+
description: "Short name of the note, e.g. \"api decision\". Writing the same key again replaces it."
|
|
1725
|
+
},
|
|
1726
|
+
text: {
|
|
1727
|
+
type: "string",
|
|
1728
|
+
description: "The whole note. Omit to delete the note instead."
|
|
1729
|
+
},
|
|
1730
|
+
private: {
|
|
1731
|
+
type: "boolean",
|
|
1732
|
+
description: "true writes your own private pad; default false writes the shared board."
|
|
1733
|
+
}
|
|
1734
|
+
},
|
|
1735
|
+
output: {
|
|
1736
|
+
schema: {
|
|
1737
|
+
type: "object",
|
|
1738
|
+
additionalProperties: false,
|
|
1739
|
+
properties: {
|
|
1740
|
+
key: {
|
|
1741
|
+
type: "string",
|
|
1742
|
+
required: true
|
|
1743
|
+
},
|
|
1744
|
+
area: {
|
|
1745
|
+
type: "string",
|
|
1746
|
+
required: true,
|
|
1747
|
+
enum: ["shared", "private"]
|
|
1748
|
+
},
|
|
1749
|
+
removed: {
|
|
1750
|
+
type: "boolean",
|
|
1751
|
+
required: true
|
|
1752
|
+
},
|
|
1753
|
+
board: {
|
|
1754
|
+
type: "array",
|
|
1755
|
+
required: true,
|
|
1756
|
+
items: {
|
|
1757
|
+
type: "object",
|
|
1758
|
+
additionalProperties: false,
|
|
1759
|
+
properties: BOARD_PROPERTIES
|
|
1760
|
+
}
|
|
1761
|
+
},
|
|
1762
|
+
at: {
|
|
1763
|
+
type: "number",
|
|
1764
|
+
required: true
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
},
|
|
1768
|
+
render: (_args, value) => [{
|
|
1769
|
+
type: "text",
|
|
1770
|
+
text: value.removed ? `dropped "${value.key}" from the ${value.area} workspace` : `wrote "${value.key}" to the ${value.area} workspace`
|
|
1771
|
+
}],
|
|
1772
|
+
presentationMeta: (_args, value) => ({
|
|
1773
|
+
team: "board",
|
|
1774
|
+
entries: value.board,
|
|
1775
|
+
at: value.at
|
|
1776
|
+
})
|
|
1777
|
+
},
|
|
1778
|
+
presentCall: (args) => call(args.text === void 0 ? `Drop note ${args.key}` : `Note: ${args.key}`, args.private === true ? { private: true } : void 0),
|
|
1779
|
+
presentResult: (args, result) => result.isError ? done(`Note: ${args.key}`, `failed: ${failureText(result)}`) : done(args.text === void 0 ? `Dropped ${args.key}` : `Noted ${args.key}`, args.private === true ? "private" : "shared"),
|
|
1780
|
+
isConcurrencySafe: () => false,
|
|
1781
|
+
async execute(args, exec) {
|
|
1782
|
+
const spot = place(seatOf(actor(exec.agent)), args.private === true);
|
|
1783
|
+
const now = Date.now();
|
|
1784
|
+
if (args.text === void 0) await workspace.remove(spot.leaderId, spot.area, args.key);
|
|
1785
|
+
else await workspace.write(spot.leaderId, spot.area, args.key, args.text, spot.author, now);
|
|
1786
|
+
return {
|
|
1787
|
+
key: args.key.trim(),
|
|
1788
|
+
area: args.private === true ? "private" : "shared",
|
|
1789
|
+
removed: args.text === void 0,
|
|
1790
|
+
board: [...await workspace.index(spot.leaderId)],
|
|
1791
|
+
at: now
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
/**
|
|
1797
|
+
* `team_board` — read a virtual workspace.
|
|
1798
|
+
* @param workspace - the open workspace domain.
|
|
1799
|
+
* @param audience - whose description this registration serves.
|
|
1800
|
+
* @param seatOf - where the caller sits (see {@link SeatResolver}).
|
|
1801
|
+
* @returns the tool definition.
|
|
1802
|
+
*/
|
|
1803
|
+
function boardTool(workspace, audience, seatOf) {
|
|
1804
|
+
return defineTool({
|
|
1805
|
+
name: "team_board",
|
|
1806
|
+
description: "Read a team workspace: the shared board every member writes to, or your own private pad. Without a key you get the index — every note with who wrote it and when — and with a key you get that note in full. " + (audience === "leader" ? "Read the board before assigning work: a teammate that has already recorded its conclusion there does not need to be asked for it again." : "Read the board before messaging anyone: what you were about to ask for may already be written down, and a note costs nobody a turn."),
|
|
1807
|
+
parameters: {
|
|
1808
|
+
key: {
|
|
1809
|
+
type: "string",
|
|
1810
|
+
description: "Read one note in full; omit for the index of the whole area."
|
|
1811
|
+
},
|
|
1812
|
+
private: {
|
|
1813
|
+
type: "boolean",
|
|
1814
|
+
description: "true reads your own private pad; default false reads the shared board."
|
|
1815
|
+
}
|
|
1816
|
+
},
|
|
1817
|
+
output: {
|
|
1818
|
+
schema: {
|
|
1819
|
+
type: "object",
|
|
1820
|
+
additionalProperties: false,
|
|
1821
|
+
properties: {
|
|
1822
|
+
area: {
|
|
1823
|
+
type: "string",
|
|
1824
|
+
required: true,
|
|
1825
|
+
enum: ["shared", "private"]
|
|
1826
|
+
},
|
|
1827
|
+
entries: {
|
|
1828
|
+
type: "array",
|
|
1829
|
+
required: true,
|
|
1830
|
+
items: {
|
|
1831
|
+
type: "object",
|
|
1832
|
+
additionalProperties: false,
|
|
1833
|
+
properties: {
|
|
1834
|
+
...BOARD_PROPERTIES,
|
|
1835
|
+
text: { type: "string" }
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
},
|
|
1839
|
+
board: {
|
|
1840
|
+
type: "array",
|
|
1841
|
+
required: true,
|
|
1842
|
+
items: {
|
|
1843
|
+
type: "object",
|
|
1844
|
+
additionalProperties: false,
|
|
1845
|
+
properties: BOARD_PROPERTIES
|
|
1846
|
+
}
|
|
1847
|
+
},
|
|
1848
|
+
at: {
|
|
1849
|
+
type: "number",
|
|
1850
|
+
required: true
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
},
|
|
1854
|
+
render: (_args, value) => [{
|
|
1855
|
+
type: "text",
|
|
1856
|
+
text: value.entries.length === 0 ? `the ${value.area} workspace is empty` : value.entries.map((entry) => `## ${entry.key} — ${entry.authorName}\n${entry.text ?? entry.preview}`).join("\n\n")
|
|
1857
|
+
}],
|
|
1858
|
+
presentationMeta: (_args, value) => ({
|
|
1859
|
+
team: "board",
|
|
1860
|
+
entries: value.board,
|
|
1861
|
+
at: value.at
|
|
1862
|
+
})
|
|
1863
|
+
},
|
|
1864
|
+
presentCall: (args) => ({
|
|
1865
|
+
card: "generic",
|
|
1866
|
+
title: args.key === void 0 ? "Read the team workspace" : `Read note ${args.key}`,
|
|
1867
|
+
kind: "read"
|
|
1868
|
+
}),
|
|
1869
|
+
presentResult: (_args, result) => result.isError ? done("Read the team workspace", "failed") : done("Team workspace"),
|
|
1870
|
+
async execute(args, exec) {
|
|
1871
|
+
const spot = place(seatOf(actor(exec.agent)), args.private === true);
|
|
1872
|
+
const held = await workspace.read(spot.leaderId, spot.area);
|
|
1873
|
+
const wanted = args.key === void 0 ? held : pickNote(held, args.key);
|
|
1874
|
+
return {
|
|
1875
|
+
area: args.private === true ? "private" : "shared",
|
|
1876
|
+
entries: wanted.map((entry) => ({
|
|
1877
|
+
key: entry.key,
|
|
1878
|
+
authorId: entry.authorId,
|
|
1879
|
+
authorName: entry.authorName,
|
|
1880
|
+
updatedAt: entry.updatedAt,
|
|
1881
|
+
preview: entry.text.split("\n")[0] ?? "",
|
|
1882
|
+
text: entry.text
|
|
1883
|
+
})),
|
|
1884
|
+
board: [...await workspace.index(spot.leaderId)],
|
|
1885
|
+
at: Date.now()
|
|
1886
|
+
};
|
|
1887
|
+
}
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
1890
|
+
//#endregion
|
|
1891
|
+
//#region src/teammate.ts
|
|
1892
|
+
/** Guidance order: after the harness tool sections, before the persona. */
|
|
1893
|
+
const TEAM_SECTION_ORDER = 118;
|
|
1894
|
+
/** The workspace paragraph sits right after the membership briefing. */
|
|
1895
|
+
const WORKSPACE_SECTION_ORDER = 119;
|
|
1896
|
+
/** What a teammate needs to know about the two workspaces it can reach. */
|
|
1897
|
+
const WORKSPACE_BRIEFING = [
|
|
1898
|
+
"Your team has two virtual workspaces, which are not files and are not in the user's working tree.",
|
|
1899
|
+
"The shared board (team_board / team_note) is what every member reads and writes: put a conclusion, a decision or hand-off material there instead of messaging it around — a note costs nobody a turn and survives after you go idle, while a message costs the recipient a turn and spends conversation budget. Read the board before you ask anyone anything; the answer may already be on it.",
|
|
1900
|
+
"Your private pad (the same tools with private=true) is yours alone: keep your own working state there so a later turn of yours can pick it up."
|
|
1901
|
+
].join("\n");
|
|
1902
|
+
/** Dispose a batch completely, then report the first failure. */
|
|
1903
|
+
function release(disposers) {
|
|
1904
|
+
const failures = [];
|
|
1905
|
+
for (const dispose of [...disposers].reverse()) try {
|
|
1906
|
+
dispose();
|
|
1907
|
+
} catch (error) {
|
|
1908
|
+
failures.push(error);
|
|
1909
|
+
}
|
|
1910
|
+
if (failures.length === 1) throw failures[0];
|
|
1911
|
+
if (failures.length > 1) throw new AggregateError(failures, "dsh-team: teammate teardown failed");
|
|
1912
|
+
}
|
|
1913
|
+
/** One roster line as a teammate reads it. */
|
|
1914
|
+
function memberLine(member) {
|
|
1915
|
+
const parts = [member.role, member.relation === "peer" ? "peer" : "managed"].filter((part) => part !== void 0);
|
|
1916
|
+
return `${member.name} (${parts.join(", ")})`;
|
|
1917
|
+
}
|
|
1918
|
+
/**
|
|
1919
|
+
* What a teammate is told when its roster row cannot be read. Two different
|
|
1920
|
+
* facts wear that one absence, and they ask for opposite things: a leader
|
|
1921
|
+
* session that is merely not loaded still has a team behind it, so the work is
|
|
1922
|
+
* worth finishing and parking on the shared board; a team that let this member
|
|
1923
|
+
* go has nobody left to read anything.
|
|
1924
|
+
*/
|
|
1925
|
+
function orphaned(ctx, child) {
|
|
1926
|
+
const leaderId = child.session.header.parentSession;
|
|
1927
|
+
if (leaderId !== void 0 && ctx.agents.get(leaderId) === void 0) return "Your team is intact, but its main session is not loaded right now, so team_send has nowhere to deliver. Your work is not lost: finish what you were asked for, write the result to the shared workspace with team_note if you have one, and stop — the leader reads it when it comes back.";
|
|
1928
|
+
return "You were part of an agent team that is no longer active: nothing you send can be delivered and nobody is waiting on you. Report what you already have and stop.";
|
|
1929
|
+
}
|
|
1930
|
+
/**
|
|
1931
|
+
* The teammate's standing briefing, re-rendered at every assembly so a member
|
|
1932
|
+
* that joins later, a promotion, or a new task is visible on the next step
|
|
1933
|
+
* without touching the child's own log.
|
|
1934
|
+
*/
|
|
1935
|
+
function briefing(ctx, team, child) {
|
|
1936
|
+
const roster = team.rosterFor(child);
|
|
1937
|
+
if (roster === void 0) return orphaned(ctx, child);
|
|
1938
|
+
const { self, others } = roster;
|
|
1939
|
+
const identity = self.role === void 0 ? `You are ${self.name}, a teammate on an agent team.` : `You are ${self.name}, the ${self.role} on an agent team.`;
|
|
1940
|
+
const reach = self.relation === "peer" ? "You are a peer member: team_send reaches the leader (\"leader\") and any teammate by name." : "You are a managed member: team_send reaches the leader (\"leader\") only.";
|
|
1941
|
+
const list = others.length === 0 ? "You are currently the only teammate." : `The rest of the team: ${others.map(memberLine).join("; ")}.`;
|
|
1942
|
+
const mine = team.list(child).tasks.filter((task) => task.assigneeId === self.memberId && task.status !== "done");
|
|
1943
|
+
return [
|
|
1944
|
+
identity,
|
|
1945
|
+
reach,
|
|
1946
|
+
list,
|
|
1947
|
+
mine.length === 0 ? "No task on the shared list is assigned to you right now." : `Assigned to you on the shared task list: ${mine.map((task) => `${task.taskId} "${task.title}"`).join("; ")}.`,
|
|
1948
|
+
"Nobody sees your session but you, so deliver results with the report tool — a self-contained answer, not \"done\". Use team_send when you need something FROM a member mid-task; the reply arrives later as its own turn, so do not wait for it in place. team_list shows the roster, the shared task list, and recent traffic. When you have reported, stop and wait for the next message instead of starting work nobody asked for. A conversation between teammates is budgeted: it may only relay so far and one pair may not keep trading messages inside it, so put everything you need into one message rather than negotiating. Reaching the leader is never refused — when an exchange with a peer stops converging, say so to the leader and move on."
|
|
1949
|
+
].join("\n");
|
|
1950
|
+
}
|
|
1951
|
+
/**
|
|
1952
|
+
* Pin one teammate's reasoning effort onto its own requests. `AgentOptions`
|
|
1953
|
+
* carries no effort — it is request-header state — so the child's scoped
|
|
1954
|
+
* request waterfall is where a per-teammate effort belongs.
|
|
1955
|
+
* @param childCtx - the teammate's scoped context.
|
|
1956
|
+
* @param effort - the provider-owned effort id recorded at spawn.
|
|
1957
|
+
* @returns the disposer for the scoped listener.
|
|
1958
|
+
*/
|
|
1959
|
+
function installEffort(childCtx, effort) {
|
|
1960
|
+
return childCtx.on("agent/request", async (_payload, next) => ({
|
|
1961
|
+
...await next(),
|
|
1962
|
+
reasoningEffort: ReasoningEffortId(effort)
|
|
1963
|
+
}));
|
|
1964
|
+
}
|
|
1965
|
+
/**
|
|
1966
|
+
* Register the teammate composition for every continuable child of a team.
|
|
1967
|
+
* @param ctx - context carrying the team and subagent services.
|
|
1968
|
+
* @returns the exact effect disposer removing the contribution.
|
|
1969
|
+
*/
|
|
1970
|
+
function installTeammateWorld(ctx) {
|
|
1971
|
+
return ctx.subagents.registerContinuableSetup((childCtx) => {
|
|
1972
|
+
const child = childCtx.agent;
|
|
1973
|
+
if (child === void 0) return () => {};
|
|
1974
|
+
const member = ctx.team.adopt(child);
|
|
1975
|
+
if (member === void 0) return () => {};
|
|
1976
|
+
const disposers = [];
|
|
1977
|
+
try {
|
|
1978
|
+
disposers.push(childCtx.systemPrompt.section({
|
|
1979
|
+
name: "team-membership",
|
|
1980
|
+
order: TEAM_SECTION_ORDER,
|
|
1981
|
+
text: () => briefing(ctx, ctx.team, child)
|
|
1982
|
+
}));
|
|
1983
|
+
disposers.push(childCtx.tools.register(sendTool(ctx, "member")));
|
|
1984
|
+
disposers.push(childCtx.tools.register(listTool(ctx, "member")));
|
|
1985
|
+
if (member.effort !== void 0) disposers.push(installEffort(childCtx, member.effort));
|
|
1986
|
+
} catch (error) {
|
|
1987
|
+
release(disposers);
|
|
1988
|
+
throw error;
|
|
1989
|
+
}
|
|
1990
|
+
return () => {
|
|
1991
|
+
release(disposers);
|
|
1992
|
+
};
|
|
1993
|
+
});
|
|
1994
|
+
}
|
|
1995
|
+
/**
|
|
1996
|
+
* Give every teammate its half of the virtual workspaces: the shared board it
|
|
1997
|
+
* writes conclusions to, and its own private pad. Registered separately from
|
|
1998
|
+
* {@link installTeammateWorld} because the workspaces are optional — a
|
|
1999
|
+
* deployment without a storage domain form composes this contribution out and
|
|
2000
|
+
* the rest of the teammate world is untouched.
|
|
2001
|
+
* @param ctx - context carrying the team service and the open workspace.
|
|
2002
|
+
* @param workspace - the open workspace domain.
|
|
2003
|
+
* @returns the exact effect disposer removing the contribution.
|
|
2004
|
+
*/
|
|
2005
|
+
function installTeammateWorkspace(ctx, workspace) {
|
|
2006
|
+
return ctx.subagents.registerContinuableSetup((childCtx) => {
|
|
2007
|
+
const child = childCtx.agent;
|
|
2008
|
+
const leaderId = child?.session.header.parentSession;
|
|
2009
|
+
const roster = child === void 0 ? void 0 : ctx.team.rosterFor(child);
|
|
2010
|
+
if (child === void 0 || leaderId === void 0 || roster === void 0) return () => {};
|
|
2011
|
+
const seat = {
|
|
2012
|
+
leaderId,
|
|
2013
|
+
memberId: child.id,
|
|
2014
|
+
name: roster.self.name
|
|
2015
|
+
};
|
|
2016
|
+
const seatOf = () => seat;
|
|
2017
|
+
const disposers = [];
|
|
2018
|
+
try {
|
|
2019
|
+
disposers.push(childCtx.systemPrompt.section({
|
|
2020
|
+
name: "team-workspace",
|
|
2021
|
+
order: WORKSPACE_SECTION_ORDER,
|
|
2022
|
+
text: WORKSPACE_BRIEFING
|
|
2023
|
+
}));
|
|
2024
|
+
disposers.push(childCtx.tools.register(noteTool(workspace, "member", seatOf)));
|
|
2025
|
+
disposers.push(childCtx.tools.register(boardTool(workspace, "member", seatOf)));
|
|
2026
|
+
} catch (error) {
|
|
2027
|
+
release(disposers);
|
|
2028
|
+
throw error;
|
|
2029
|
+
}
|
|
2030
|
+
return () => {
|
|
2031
|
+
release(disposers);
|
|
2032
|
+
};
|
|
2033
|
+
});
|
|
2034
|
+
}
|
|
2035
|
+
//#endregion
|
|
2036
|
+
//#region src/index.ts
|
|
2037
|
+
const name = "team";
|
|
2038
|
+
/**
|
|
2039
|
+
* `tools` and `systemPrompt` are declared although this row registers into
|
|
2040
|
+
* agent scopes rather than the root registry: a Loader ordering mistake then
|
|
2041
|
+
* fails at load instead of at the next session or teammate.
|
|
2042
|
+
*/
|
|
2043
|
+
const inject = [
|
|
2044
|
+
"agents",
|
|
2045
|
+
"subagents",
|
|
2046
|
+
"sessionProjections",
|
|
2047
|
+
"tools",
|
|
2048
|
+
"systemPrompt"
|
|
2049
|
+
];
|
|
2050
|
+
/** A team leader is any ordinary session; a teammate never leads its own team. */
|
|
2051
|
+
function leads(agent) {
|
|
2052
|
+
return agent.session.header.origin !== "subagent";
|
|
2053
|
+
}
|
|
2054
|
+
/**
|
|
2055
|
+
* Install the leader tool set into one session's own agent scope, so an
|
|
2056
|
+
* ordinary subagent — which inherits the global registry but not this scope —
|
|
2057
|
+
* never sees tools that would fail for it.
|
|
2058
|
+
* @param ctx - context carrying the team service.
|
|
2059
|
+
* @param agent - the session agent to equip.
|
|
2060
|
+
* @returns the disposer for every registration made here.
|
|
2061
|
+
*/
|
|
2062
|
+
function installLeaderTools(ctx, agent) {
|
|
2063
|
+
const disposers = [
|
|
2064
|
+
agent.ctx.tools.register(spawnTool(ctx)),
|
|
2065
|
+
agent.ctx.tools.register(sendTool(ctx, "leader")),
|
|
2066
|
+
agent.ctx.tools.register(taskTool(ctx)),
|
|
2067
|
+
agent.ctx.tools.register(relationTool(ctx)),
|
|
2068
|
+
agent.ctx.tools.register(dismissTool(ctx)),
|
|
2069
|
+
agent.ctx.tools.register(listTool(ctx))
|
|
2070
|
+
];
|
|
2071
|
+
return () => {
|
|
2072
|
+
for (const dispose of disposers.reverse()) dispose();
|
|
2073
|
+
};
|
|
2074
|
+
}
|
|
2075
|
+
/**
|
|
2076
|
+
* Equip every leader session, now and as sessions arrive, with one tool set.
|
|
2077
|
+
* @param ctx - the context owning the registrations.
|
|
2078
|
+
* @param install - what to register into one leader's own agent scope.
|
|
2079
|
+
* @returns the disposer taking the tools off every session that outlives it.
|
|
2080
|
+
*/
|
|
2081
|
+
function equipLeaders(ctx, install) {
|
|
2082
|
+
const equipped = /* @__PURE__ */ new Map();
|
|
2083
|
+
const equip = (agent) => {
|
|
2084
|
+
if (!leads(agent) || equipped.has(agent.id)) return;
|
|
2085
|
+
equipped.set(agent.id, install(agent));
|
|
2086
|
+
};
|
|
2087
|
+
ctx.on("agent/created", (payload) => {
|
|
2088
|
+
equip(payload.agent);
|
|
2089
|
+
});
|
|
2090
|
+
ctx.on("agent/disposed", (payload) => {
|
|
2091
|
+
equipped.delete(payload.agent.id);
|
|
2092
|
+
});
|
|
2093
|
+
for (const agent of ctx.agents.list()) equip(agent);
|
|
2094
|
+
return () => {
|
|
2095
|
+
for (const dispose of equipped.values()) dispose();
|
|
2096
|
+
equipped.clear();
|
|
2097
|
+
};
|
|
2098
|
+
}
|
|
2099
|
+
/**
|
|
2100
|
+
* The virtual workspaces, when the deployment composed a storage domain form.
|
|
2101
|
+
* Without one the team keeps everything else and the workspace tools are never
|
|
2102
|
+
* registered — no member sees a tool that has nowhere to write.
|
|
2103
|
+
* @param ctx - a context whose `team` service is resolved.
|
|
2104
|
+
* @param config - the validated row configuration.
|
|
2105
|
+
*/
|
|
2106
|
+
function installWorkspaces(ctx, config) {
|
|
2107
|
+
ctx.inject(["storageDomain"], (workspaceCtx) => {
|
|
2108
|
+
const workspace = new TeamWorkspace(workspaceCtx, config);
|
|
2109
|
+
workspaceCtx.effect(() => () => {
|
|
2110
|
+
workspace.dispose();
|
|
2111
|
+
}, "team: workspace domain");
|
|
2112
|
+
workspaceCtx.on("team/changed", (payload) => {
|
|
2113
|
+
if (payload.ended === true) workspace.clear(payload.leaderId);
|
|
2114
|
+
else if (payload.removedMember !== void 0) workspace.clear(payload.leaderId, payload.removedMember);
|
|
2115
|
+
});
|
|
2116
|
+
workspaceCtx.effect(() => equipLeaders(workspaceCtx, (agent) => {
|
|
2117
|
+
const disposers = [agent.ctx.tools.register(noteTool(workspace, "leader", (actor) => workspaceCtx.team.seatOf(actor))), agent.ctx.tools.register(boardTool(workspace, "leader", (actor) => workspaceCtx.team.seatOf(actor)))];
|
|
2118
|
+
return () => {
|
|
2119
|
+
for (const dispose of disposers.reverse()) dispose();
|
|
2120
|
+
};
|
|
2121
|
+
}), "team: leader workspace tools");
|
|
2122
|
+
workspaceCtx.effect(() => installTeammateWorkspace(workspaceCtx, workspace), "team: teammate workspace tools");
|
|
2123
|
+
});
|
|
2124
|
+
}
|
|
2125
|
+
/**
|
|
2126
|
+
* Compose the team capability: the service, the durable projection unit, the
|
|
2127
|
+
* teammate world, the per-session leader tools, and the virtual workspaces.
|
|
2128
|
+
* @param ctx - the row's context.
|
|
2129
|
+
* @param config - the validated row configuration.
|
|
2130
|
+
*/
|
|
2131
|
+
function apply(ctx, config) {
|
|
2132
|
+
ctx.plugin(TeamService, config);
|
|
2133
|
+
ctx.inject(["team"], (teamCtx) => {
|
|
2134
|
+
teamCtx.effect(() => teamCtx.sessionProjections.register(teamProjection(config.maxRecentMessages)), "team: durable projection unit");
|
|
2135
|
+
teamCtx.effect(() => installTeammateWorld(teamCtx), "team: teammate world");
|
|
2136
|
+
teamCtx.effect(() => equipLeaders(teamCtx, (agent) => installLeaderTools(teamCtx, agent)), "team: leader tools");
|
|
2137
|
+
installWorkspaces(teamCtx, config);
|
|
2138
|
+
});
|
|
2139
|
+
}
|
|
2140
|
+
//#endregion
|
|
2141
|
+
export { Config, EMPTY_TEAM_VIEW, SHARED_AREA, TEAM_PROJECTION_KEY, TeamError, TeamService, TeamWorkspace, WORKSPACE_DOMAIN, apply, foldTeam, inject, name, teamProjection };
|