@deepseek-ai/dsh-experimental-agent-team 0.1.5-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +220 -0
- package/README.zh.md +220 -0
- package/lib/index.js +1894 -0
- package/lib/invariant.js +361 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +867 -0
- package/lib/typert.remote-client.d.ts +32 -0
- package/lib/typert.remote-client.js +210 -0
- package/lib/types/activity.d.ts +23 -0
- package/lib/types/activity.js +85 -0
- package/lib/types/client.d.ts +3 -0
- package/lib/types/client.js +3 -0
- package/lib/types/error.d.ts +13 -0
- package/lib/types/error.js +23 -0
- package/lib/types/index.d.ts +137 -0
- package/lib/types/index.js +313 -0
- package/lib/types/invariant.d.ts +9 -0
- package/lib/types/invariant.js +26 -0
- package/lib/types/journal.d.ts +39 -0
- package/lib/types/journal.js +63 -0
- package/lib/types/lifecycle.d.ts +33 -0
- package/lib/types/lifecycle.js +85 -0
- package/lib/types/mailbox.d.ts +78 -0
- package/lib/types/mailbox.js +292 -0
- package/lib/types/persisted.d.ts +20 -0
- package/lib/types/persisted.js +20 -0
- package/lib/types/projection.d.ts +48 -0
- package/lib/types/projection.js +260 -0
- package/lib/types/roster.d.ts +113 -0
- package/lib/types/roster.js +446 -0
- package/lib/types/session-message.d.ts +11 -0
- package/lib/types/session-message.js +23 -0
- package/lib/types/task-board.d.ts +62 -0
- package/lib/types/task-board.js +275 -0
- package/lib/types/task-graph.d.ts +21 -0
- package/lib/types/task-graph.js +62 -0
- package/lib/types/types.d.ts +206 -0
- package/lib/types/types.js +26 -0
- package/lib/types/validation.d.ts +16 -0
- package/lib/types/validation.js +33 -0
- package/package.json +88 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/** Durable Team mailbox admission, target-local dispatch, acknowledgement, and recovery. */
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { brandString } from '@deepseek-ai/dsh-brand';
|
|
4
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
5
|
+
import { steerHostSubagentPrompt } from '@deepseek-ai/dsh-subagent/internal';
|
|
6
|
+
import { errorMessage, TeamError } from "./error.js";
|
|
7
|
+
import { readPersistedSession } from "./persisted.js";
|
|
8
|
+
import { resolveActiveMember } from "./roster.js";
|
|
9
|
+
import { messageAccepted } from "./session-message.js";
|
|
10
|
+
import { TeamId, TeamMessageId } from "./types.js";
|
|
11
|
+
/** Owns every process-local state transition for the durable Team mailbox. */
|
|
12
|
+
export class TeamMailbox {
|
|
13
|
+
ctx;
|
|
14
|
+
journal;
|
|
15
|
+
roster;
|
|
16
|
+
lifecycle;
|
|
17
|
+
maxPendingMessagesPerMember;
|
|
18
|
+
maxMessageBytes;
|
|
19
|
+
dispatchTails = new Map();
|
|
20
|
+
inFlightMessages = new Set();
|
|
21
|
+
inFlightDispatches = new Set();
|
|
22
|
+
/**
|
|
23
|
+
* @param ctx - Team service context with Agent, Session, persistence, and subagent services.
|
|
24
|
+
* @param journal - authoritative Lead-log transaction owner.
|
|
25
|
+
* @param roster - Team membership and member-name resolver.
|
|
26
|
+
* @param lifecycle - shared Team runtime admission cutoff.
|
|
27
|
+
* @param maxPendingMessagesPerMember - per-target queued-minus-delivered limit.
|
|
28
|
+
* @param maxMessageBytes - maximum complete sender-framed delivery size.
|
|
29
|
+
*/
|
|
30
|
+
constructor(ctx, journal, roster, lifecycle, maxPendingMessagesPerMember, maxMessageBytes) {
|
|
31
|
+
this.ctx = ctx;
|
|
32
|
+
this.journal = journal;
|
|
33
|
+
this.roster = roster;
|
|
34
|
+
this.lifecycle = lifecycle;
|
|
35
|
+
this.maxPendingMessagesPerMember = maxPendingMessagesPerMember;
|
|
36
|
+
this.maxMessageBytes = maxMessageBytes;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Queue one durable peer message, then attempt immediate delivery.
|
|
40
|
+
* @param caller - exact live sending Team member.
|
|
41
|
+
* @param request - target name, content, and pre-queue cancellation.
|
|
42
|
+
* @returns durable message identity and immediate-delivery observation.
|
|
43
|
+
*/
|
|
44
|
+
async send(caller, request) {
|
|
45
|
+
if (this.lifecycle.disposed)
|
|
46
|
+
throw new TeamError('Agent Teams service is disposing', 'TEAM_DISPOSED');
|
|
47
|
+
const operation = this.sendAdmitted(caller, {
|
|
48
|
+
...request,
|
|
49
|
+
signal: AbortSignal.any([request.signal, this.lifecycle.signal]),
|
|
50
|
+
});
|
|
51
|
+
return await this.trackDispatch(operation);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Observe target-side durable receipts and checkpoint their Lead-log acknowledgement.
|
|
55
|
+
* @param session - exact target Session receiving the event.
|
|
56
|
+
* @param event - newly appended Session event.
|
|
57
|
+
*/
|
|
58
|
+
observeSessionEvent(session, event) {
|
|
59
|
+
if (this.lifecycle.disposed || event.type !== 'user/message' || event.data.source.kind !== 'team-message')
|
|
60
|
+
return;
|
|
61
|
+
const source = event.data.source;
|
|
62
|
+
const acknowledgement = Promise.resolve().then(async () => {
|
|
63
|
+
const root = this.ctx.agents.get(brandString(source.teamId));
|
|
64
|
+
if (root !== undefined)
|
|
65
|
+
await this.checkpointDelivered(root, session, source.messageId);
|
|
66
|
+
}).catch((error) => {
|
|
67
|
+
this.ctx.logger.warn(`Team message "${source.messageId}" acknowledgement failed: ${errorMessage(error)}`);
|
|
68
|
+
});
|
|
69
|
+
void this.trackDispatch(acknowledgement);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Retry durable pending messages relevant to one started Team member.
|
|
73
|
+
* @param agent - newly started exact live Agent.
|
|
74
|
+
* @param signal - shared runtime cancellation.
|
|
75
|
+
*/
|
|
76
|
+
async recoverFor(agent, signal) {
|
|
77
|
+
signal.throwIfAborted();
|
|
78
|
+
const membership = this.roster.tryMembership(agent);
|
|
79
|
+
if (membership === undefined)
|
|
80
|
+
return;
|
|
81
|
+
const state = this.journal.state(membership.root);
|
|
82
|
+
const messages = state.messages.filter(message => !state.delivered.includes(message.id)
|
|
83
|
+
&& (membership.role === 'lead' || message.targetId === agent.id));
|
|
84
|
+
for (const message of messages) {
|
|
85
|
+
signal.throwIfAborted();
|
|
86
|
+
await this.tryDispatch(membership.root, message, signal);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Return admitted dispatch and acknowledgement operations captured for disposal.
|
|
91
|
+
* @returns detached snapshot ordered only by Set insertion.
|
|
92
|
+
*/
|
|
93
|
+
pendingDispatches() {
|
|
94
|
+
return [...this.inFlightDispatches];
|
|
95
|
+
}
|
|
96
|
+
/** Queue and dispatch one mailbox item admitted before the disposal cutoff. */
|
|
97
|
+
async sendAdmitted(caller, request) {
|
|
98
|
+
const membership = this.roster.membership(caller);
|
|
99
|
+
request.signal.throwIfAborted();
|
|
100
|
+
const root = membership.root;
|
|
101
|
+
const content = structuredClone(request.content);
|
|
102
|
+
const queued = await this.journal.transact(root.id, async () => {
|
|
103
|
+
request.signal.throwIfAborted();
|
|
104
|
+
const state = this.journal.state(root);
|
|
105
|
+
const target = resolveActiveMember(root, state, request.target);
|
|
106
|
+
if (target.id === caller.id)
|
|
107
|
+
throw new TeamError('a Team member cannot message itself', 'TEAM_SELF_MESSAGE');
|
|
108
|
+
const pendingForTarget = state.messages.filter(candidate => candidate.targetId === target.id && !state.delivered.includes(candidate.id)).length;
|
|
109
|
+
if (pendingForTarget >= this.maxPendingMessagesPerMember) {
|
|
110
|
+
throw new TeamError(`teammate "${target.name}" has ${pendingForTarget} pending messages`, 'TEAM_MAILBOX_FULL');
|
|
111
|
+
}
|
|
112
|
+
const queued = {
|
|
113
|
+
id: TeamMessageId(`team-message-${randomUUID()}`),
|
|
114
|
+
senderId: caller.id,
|
|
115
|
+
senderName: membership.name,
|
|
116
|
+
targetId: target.id,
|
|
117
|
+
content,
|
|
118
|
+
};
|
|
119
|
+
if (Buffer.byteLength(JSON.stringify(this.deliveryContent(queued)), 'utf8') > this.maxMessageBytes) {
|
|
120
|
+
throw new TeamError(`team message exceeds ${this.maxMessageBytes} bytes`, 'TEAM_MESSAGE_TOO_LARGE');
|
|
121
|
+
}
|
|
122
|
+
await this.journal.appendAndFlush(root, 'team/message/queued', {
|
|
123
|
+
version: 2,
|
|
124
|
+
teamId: TeamId(root.id),
|
|
125
|
+
message: queued,
|
|
126
|
+
});
|
|
127
|
+
// Register dispatch before releasing the root transaction so concurrent
|
|
128
|
+
// senders enter the target-local queue in durable mailbox order.
|
|
129
|
+
return { message: queued, dispatch: this.tryDispatch(root, queued, request.signal) };
|
|
130
|
+
});
|
|
131
|
+
const accepted = await queued.dispatch;
|
|
132
|
+
return { messageId: queued.message.id, status: accepted ? 'accepted' : 'queued' };
|
|
133
|
+
}
|
|
134
|
+
/** Attempt one queued message exactly once in this process at a time. */
|
|
135
|
+
tryDispatch(root, message, signal) {
|
|
136
|
+
if (this.lifecycle.disposed)
|
|
137
|
+
return Promise.resolve(false);
|
|
138
|
+
if (this.inFlightMessages.has(message.id))
|
|
139
|
+
return Promise.resolve(false);
|
|
140
|
+
this.inFlightMessages.add(message.id);
|
|
141
|
+
const operation = this.trackDispatch(this.tryDispatchAdmitted(root, message, AbortSignal.any([signal, this.lifecycle.signal])));
|
|
142
|
+
const forget = () => {
|
|
143
|
+
this.inFlightMessages.delete(message.id);
|
|
144
|
+
};
|
|
145
|
+
void operation.then(forget, forget);
|
|
146
|
+
return operation;
|
|
147
|
+
}
|
|
148
|
+
/** Track one dispatch transaction through delivery admission or contained failure. */
|
|
149
|
+
trackDispatch(operation) {
|
|
150
|
+
this.inFlightDispatches.add(operation);
|
|
151
|
+
void operation.then(() => {
|
|
152
|
+
this.inFlightDispatches.delete(operation);
|
|
153
|
+
}, () => {
|
|
154
|
+
this.inFlightDispatches.delete(operation);
|
|
155
|
+
});
|
|
156
|
+
return operation;
|
|
157
|
+
}
|
|
158
|
+
/** Attempt one queued message admitted before the service lifecycle cutoff. */
|
|
159
|
+
async tryDispatchAdmitted(root, message, signal) {
|
|
160
|
+
return await this.serializeDispatch(message, () => this.dispatchThrough(root, message, signal));
|
|
161
|
+
}
|
|
162
|
+
/** Serialize delivery admission for one durable target in queued order. */
|
|
163
|
+
async serializeDispatch(message, operation) {
|
|
164
|
+
const targetId = message.targetId;
|
|
165
|
+
const prior = this.dispatchTails.get(targetId) ?? Promise.resolve();
|
|
166
|
+
/* v8 ignore next -- dispatch tails absorb rejection, so the recovery callback is a fail-safe backstop. */
|
|
167
|
+
const run = prior.then(operation, operation);
|
|
168
|
+
/* v8 ignore next -- dispatchOnce contains delivery failures and serializeDispatch itself does not throw. */
|
|
169
|
+
const tail = run.then(() => undefined, () => undefined);
|
|
170
|
+
this.dispatchTails.set(targetId, tail);
|
|
171
|
+
try {
|
|
172
|
+
return await run;
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
if (this.dispatchTails.get(targetId) === tail)
|
|
176
|
+
this.dispatchTails.delete(targetId);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** Deliver every pending target message through `message` in durable queue order. */
|
|
180
|
+
async dispatchThrough(root, message, signal) {
|
|
181
|
+
const state = this.journal.state(root);
|
|
182
|
+
const pending = state.messages.filter(candidate => candidate.targetId === message.targetId && !state.delivered.includes(candidate.id));
|
|
183
|
+
const requested = pending.findIndex(candidate => candidate.id === message.id);
|
|
184
|
+
if (requested < 0)
|
|
185
|
+
return state.delivered.includes(message.id);
|
|
186
|
+
for (const candidate of pending.slice(0, requested + 1)) {
|
|
187
|
+
const ownsInFlight = !this.inFlightMessages.has(candidate.id);
|
|
188
|
+
if (ownsInFlight)
|
|
189
|
+
this.inFlightMessages.add(candidate.id);
|
|
190
|
+
try {
|
|
191
|
+
if (!await this.dispatchOnce(root, candidate, signal))
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
if (ownsInFlight)
|
|
196
|
+
this.inFlightMessages.delete(candidate.id);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
/** Attempt one queued delivery after target-local ordering admits it. */
|
|
202
|
+
async dispatchOnce(root, message, signal) {
|
|
203
|
+
try {
|
|
204
|
+
const target = message.targetId === root.id ? root : this.ctx.agents.get(message.targetId);
|
|
205
|
+
if (target !== undefined && this.targetRecorded(target.session, message.id)) {
|
|
206
|
+
return await this.checkpointDelivered(root, target.session, message.id);
|
|
207
|
+
}
|
|
208
|
+
const source = {
|
|
209
|
+
kind: 'team-message',
|
|
210
|
+
teamId: TeamId(root.id),
|
|
211
|
+
messageId: message.id,
|
|
212
|
+
senderId: message.senderId,
|
|
213
|
+
senderName: message.senderName,
|
|
214
|
+
};
|
|
215
|
+
const content = this.deliveryContent(message);
|
|
216
|
+
if (message.targetId === root.id) {
|
|
217
|
+
const input = createUserMessage({ content, source });
|
|
218
|
+
root.steer(input);
|
|
219
|
+
return await this.checkpointDelivered(root, root.session, message.id);
|
|
220
|
+
}
|
|
221
|
+
if (target === undefined) {
|
|
222
|
+
const recorded = await this.persistedTargetRecorded(message.targetId, message.id, signal);
|
|
223
|
+
if (recorded === undefined)
|
|
224
|
+
return false;
|
|
225
|
+
if (recorded) {
|
|
226
|
+
await this.markDelivered(root, message.id, message.targetId);
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
await steerHostSubagentPrompt(this.ctx.subagents, root, message.targetId, content, source, signal);
|
|
231
|
+
return target === undefined
|
|
232
|
+
? true
|
|
233
|
+
: await this.checkpointDelivered(root, target.session, message.id);
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
this.ctx.logger.warn(`team message "${message.id}" remains queued: ${errorMessage(error)}`);
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/** Flush one live target receipt before the Lead records its delivered edge. */
|
|
241
|
+
async checkpointDelivered(root, target, messageId) {
|
|
242
|
+
await this.ctx.sessions.flush(target);
|
|
243
|
+
if (!this.targetRecorded(target, messageId))
|
|
244
|
+
return false;
|
|
245
|
+
await this.markDelivered(root, messageId, target.id);
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
/** Record delivery unless the acknowledgement already exists. */
|
|
249
|
+
async markDelivered(root, messageId, targetId) {
|
|
250
|
+
await this.journal.transact(root.id, async () => {
|
|
251
|
+
const state = this.journal.state(root);
|
|
252
|
+
if (state.delivered.includes(messageId))
|
|
253
|
+
return;
|
|
254
|
+
const queued = state.messages.find(message => message.id === messageId);
|
|
255
|
+
if (queued === undefined || queued.targetId !== targetId)
|
|
256
|
+
return;
|
|
257
|
+
await this.journal.appendAndFlush(root, 'team/message/delivered', {
|
|
258
|
+
version: 2,
|
|
259
|
+
teamId: TeamId(root.id),
|
|
260
|
+
messageId,
|
|
261
|
+
targetId,
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
/** Whether a target Session already contains the durable message identity. */
|
|
266
|
+
targetRecorded(session, messageId) {
|
|
267
|
+
const suffix = session.snapshotEvents(session.inheritedEventCount);
|
|
268
|
+
return messageAccepted(suffix, message => message.source.kind === 'team-message'
|
|
269
|
+
&& message.source.messageId === messageId);
|
|
270
|
+
}
|
|
271
|
+
/** Frame peer content with stable sender and message identity for the receiving model. */
|
|
272
|
+
deliveryContent(message) {
|
|
273
|
+
return [
|
|
274
|
+
{ type: 'text', text: `Team message ${message.id} from ${message.senderName}:` },
|
|
275
|
+
...structuredClone(message.content),
|
|
276
|
+
];
|
|
277
|
+
}
|
|
278
|
+
/** Read an inactive target's durable log before cold resume; uncertainty keeps the mailbox queued. */
|
|
279
|
+
async persistedTargetRecorded(targetId, messageId, signal) {
|
|
280
|
+
try {
|
|
281
|
+
const stored = await readPersistedSession(this.ctx.sessionPersistence, targetId, signal);
|
|
282
|
+
const suffix = stored.events.slice(stored.inheritedEventCount);
|
|
283
|
+
return messageAccepted(suffix, message => message.source.kind === 'team-message'
|
|
284
|
+
&& message.source.messageId === messageId);
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
this.ctx.logger.warn(`cannot read Team message target "${targetId}": ${errorMessage(error)}`);
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
//# sourceMappingURL=mailbox.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Short-lived read-handle access to persisted Team member Sessions. */
|
|
2
|
+
import type { SessionEvent, SessionHeader, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session';
|
|
3
|
+
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence';
|
|
4
|
+
/** One persisted Session's detached header and complete committed event log. */
|
|
5
|
+
export interface PersistedSessionView {
|
|
6
|
+
/** Exact fork-inherited event count paired with `header`. */
|
|
7
|
+
readonly inheritedEventCount: SessionLogOffset;
|
|
8
|
+
readonly header: SessionHeader;
|
|
9
|
+
readonly events: readonly SessionEvent[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Read one stored session's header and complete event log through a
|
|
13
|
+
* short-lived read handle, closing the handle before returning.
|
|
14
|
+
* @param persistence - the durable session store.
|
|
15
|
+
* @param id - the stored session to read.
|
|
16
|
+
* @param signal - cancellation observed by open and read.
|
|
17
|
+
* @returns the stored header and every committed event.
|
|
18
|
+
*/
|
|
19
|
+
export declare function readPersistedSession(persistence: SessionPersistence, id: SessionId, signal: AbortSignal): Promise<PersistedSessionView>;
|
|
20
|
+
//# sourceMappingURL=persisted.d.ts.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Short-lived read-handle access to persisted Team member Sessions. */
|
|
2
|
+
/**
|
|
3
|
+
* Read one stored session's header and complete event log through a
|
|
4
|
+
* short-lived read handle, closing the handle before returning.
|
|
5
|
+
* @param persistence - the durable session store.
|
|
6
|
+
* @param id - the stored session to read.
|
|
7
|
+
* @param signal - cancellation observed by open and read.
|
|
8
|
+
* @returns the stored header and every committed event.
|
|
9
|
+
*/
|
|
10
|
+
export async function readPersistedSession(persistence, id, signal) {
|
|
11
|
+
const handle = await persistence.open(id, 'read', { signal });
|
|
12
|
+
try {
|
|
13
|
+
const { events } = await handle.read(0, undefined, { signal });
|
|
14
|
+
return { header: handle.header, inheritedEventCount: handle.inheritedEventCount, events };
|
|
15
|
+
}
|
|
16
|
+
finally {
|
|
17
|
+
await handle.close();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=persisted.js.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Host-only Team state projected incrementally from committed Session events. */
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session';
|
|
4
|
+
import type { TeamId, TeamMemberSnapshot, TeamMessageId, TeamMessageSnapshot, TeamTaskSnapshot } from './types.ts';
|
|
5
|
+
/** Current Team state selected by durable Team identity. */
|
|
6
|
+
export interface TeamState {
|
|
7
|
+
readonly id: TeamId;
|
|
8
|
+
readonly members: TeamMemberSnapshot[];
|
|
9
|
+
readonly tasks: TeamTaskSnapshot[];
|
|
10
|
+
readonly messages: TeamMessageSnapshot[];
|
|
11
|
+
readonly delivered: TeamMessageId[];
|
|
12
|
+
nextTaskNumber: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Construct empty state for one Team identity.
|
|
16
|
+
* @param rootId - root Session identity.
|
|
17
|
+
* @returns mutable empty Team state.
|
|
18
|
+
*/
|
|
19
|
+
export declare function emptyTeamState(rootId: SessionId): TeamProjectionState;
|
|
20
|
+
/** Checkpoint-safe state for the Team owned by the projected Session. */
|
|
21
|
+
export interface TeamProjectionState extends TeamState {
|
|
22
|
+
failure?: string;
|
|
23
|
+
}
|
|
24
|
+
declare module '@deepseek-ai/dsh-session-projection/types' {
|
|
25
|
+
interface SessionProjectionStateMap {
|
|
26
|
+
agentTeam: TeamProjectionState;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Whether one event belongs to the Team domain. */
|
|
30
|
+
export type TeamEventType = 'team/member' | 'team/task' | 'team/message/queued' | 'team/message/delivered';
|
|
31
|
+
/** One event owned by the Team domain. */
|
|
32
|
+
type TeamSessionEvent = SessionEvent<TeamEventType>;
|
|
33
|
+
/**
|
|
34
|
+
* Test whether a Session event belongs to the Team domain.
|
|
35
|
+
* @param event - candidate Session event.
|
|
36
|
+
* @returns whether the event has a Team-owned type.
|
|
37
|
+
*/
|
|
38
|
+
export declare function isTeamEvent(event: SessionEvent): event is TeamSessionEvent;
|
|
39
|
+
/** Host-only Team projection selected by the projected Session identity. */
|
|
40
|
+
export declare const teamProjectionDefinition: {
|
|
41
|
+
key: "agentTeam";
|
|
42
|
+
stateVersion: number;
|
|
43
|
+
stateSchema: z.ZodType<TeamProjectionState, unknown, z.core.$ZodTypeInternals<TeamProjectionState, unknown>>;
|
|
44
|
+
init: (header: import("@deepseek-ai/dsh-session").SessionHeader) => TeamProjectionState;
|
|
45
|
+
apply: (state: NoInfer<TeamProjectionState>, event: SessionEvent) => TeamProjectionState;
|
|
46
|
+
};
|
|
47
|
+
export {};
|
|
48
|
+
//# sourceMappingURL=projection.d.ts.map
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/** Host-only Team state projected incrementally from committed Session events. */
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { brandString } from '@deepseek-ai/dsh-brand';
|
|
4
|
+
import { TeamId as toTeamId, TeamMessageId as toTeamMessageId, TeamTaskId as toTeamTaskId, } from "./types.js";
|
|
5
|
+
import { assertTaskGraphCandidate } from "./task-graph.js";
|
|
6
|
+
const nonNegativeSafeInteger = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
|
|
7
|
+
const positiveSafeInteger = nonNegativeSafeInteger.min(1);
|
|
8
|
+
const sessionIdSchema = z.string().min(1).transform(value => brandString(value));
|
|
9
|
+
const teamIdSchema = z.string().min(1).transform(value => toTeamId(value));
|
|
10
|
+
const numericTaskIdPattern = /^task-(\d+)$/u;
|
|
11
|
+
const teamTaskIdSchema = z.string().min(1).refine((value) => {
|
|
12
|
+
const match = numericTaskIdPattern.exec(value);
|
|
13
|
+
return match === null || Number.isSafeInteger(Number(match[1]));
|
|
14
|
+
}, { message: 'numeric task id suffix must be a safe integer' }).transform(value => toTeamTaskId(value));
|
|
15
|
+
const teamMessageIdSchema = z.string().min(1).transform(value => toTeamMessageId(value));
|
|
16
|
+
const coreContentBlockTypes = new Set(['text', 'reasoning', 'image', 'tool-call', 'tool-result']);
|
|
17
|
+
const imageAttachmentSchema = z.object({
|
|
18
|
+
attachmentId: z.string().min(1),
|
|
19
|
+
mediaType: z.enum(['image/png', 'image/jpeg', 'image/webp', 'image/gif']),
|
|
20
|
+
bytes: nonNegativeSafeInteger,
|
|
21
|
+
width: positiveSafeInteger,
|
|
22
|
+
height: positiveSafeInteger,
|
|
23
|
+
name: z.string().optional(),
|
|
24
|
+
}).strict();
|
|
25
|
+
// ContentBlockMap is merge-extensible. Validate every core variant exactly,
|
|
26
|
+
// while retaining JSON-decoded plugin variants under an unknown type tag.
|
|
27
|
+
const contentBlockSchema = z.lazy(() => z.union([
|
|
28
|
+
z.object({ type: z.literal('text'), text: z.string() }).strict(),
|
|
29
|
+
z.object({ type: z.literal('reasoning'), text: z.string() }).strict(),
|
|
30
|
+
z.object({ type: z.literal('image'), attachment: imageAttachmentSchema }).strict(),
|
|
31
|
+
z.object({
|
|
32
|
+
type: z.literal('tool-call'),
|
|
33
|
+
id: z.string().min(1),
|
|
34
|
+
name: z.string(),
|
|
35
|
+
arguments: z.string(),
|
|
36
|
+
}).strict(),
|
|
37
|
+
z.object({
|
|
38
|
+
type: z.literal('tool-result'),
|
|
39
|
+
toolCallId: z.string().min(1),
|
|
40
|
+
content: z.array(contentBlockSchema),
|
|
41
|
+
isError: z.boolean().optional(),
|
|
42
|
+
}).strict(),
|
|
43
|
+
z.object({ type: z.string().min(1) }).loose().refine(block => !coreContentBlockTypes.has(block.type), { message: 'known content block types must match their declared fields' }),
|
|
44
|
+
]));
|
|
45
|
+
const teamMemberSnapshotSchema = z.object({
|
|
46
|
+
id: sessionIdSchema,
|
|
47
|
+
name: z.string(),
|
|
48
|
+
description: z.string(),
|
|
49
|
+
provider: z.string(),
|
|
50
|
+
context: z.enum(['fresh', 'fork']),
|
|
51
|
+
phase: z.enum(['provisioning', 'active', 'failed']),
|
|
52
|
+
error: z.string().optional(),
|
|
53
|
+
}).strict();
|
|
54
|
+
const teamTaskSnapshotSchema = z.object({
|
|
55
|
+
id: teamTaskIdSchema,
|
|
56
|
+
revision: positiveSafeInteger,
|
|
57
|
+
subject: z.string(),
|
|
58
|
+
description: z.string(),
|
|
59
|
+
status: z.enum(['pending', 'in_progress', 'completed', 'deleted']),
|
|
60
|
+
ownerId: sessionIdSchema.optional(),
|
|
61
|
+
blockedBy: z.array(teamTaskIdSchema),
|
|
62
|
+
writeScopes: z.array(z.string()),
|
|
63
|
+
}).strict();
|
|
64
|
+
const teamMessageSnapshotSchema = z.object({
|
|
65
|
+
id: teamMessageIdSchema,
|
|
66
|
+
senderId: sessionIdSchema,
|
|
67
|
+
senderName: z.string(),
|
|
68
|
+
targetId: sessionIdSchema,
|
|
69
|
+
content: z.array(contentBlockSchema),
|
|
70
|
+
}).strict();
|
|
71
|
+
const teamEventSelectorSchema = z.object({
|
|
72
|
+
version: nonNegativeSafeInteger,
|
|
73
|
+
teamId: teamIdSchema,
|
|
74
|
+
}).loose();
|
|
75
|
+
const teamMemberEventSchema = z.object({
|
|
76
|
+
version: z.literal(2),
|
|
77
|
+
teamId: teamIdSchema,
|
|
78
|
+
member: teamMemberSnapshotSchema,
|
|
79
|
+
}).strict();
|
|
80
|
+
const teamTaskEventSchema = z.object({
|
|
81
|
+
version: z.literal(2),
|
|
82
|
+
teamId: teamIdSchema,
|
|
83
|
+
task: teamTaskSnapshotSchema,
|
|
84
|
+
}).strict();
|
|
85
|
+
const teamMessageQueuedEventSchema = z.object({
|
|
86
|
+
version: z.literal(2),
|
|
87
|
+
teamId: teamIdSchema,
|
|
88
|
+
message: teamMessageSnapshotSchema,
|
|
89
|
+
}).strict();
|
|
90
|
+
const teamMessageDeliveredEventSchema = z.object({
|
|
91
|
+
version: z.literal(2),
|
|
92
|
+
teamId: teamIdSchema,
|
|
93
|
+
messageId: teamMessageIdSchema,
|
|
94
|
+
targetId: sessionIdSchema,
|
|
95
|
+
}).strict();
|
|
96
|
+
/**
|
|
97
|
+
* Construct empty state for one Team identity.
|
|
98
|
+
* @param rootId - root Session identity.
|
|
99
|
+
* @returns mutable empty Team state.
|
|
100
|
+
*/
|
|
101
|
+
export function emptyTeamState(rootId) {
|
|
102
|
+
return {
|
|
103
|
+
id: toTeamId(rootId),
|
|
104
|
+
members: [],
|
|
105
|
+
tasks: [],
|
|
106
|
+
messages: [],
|
|
107
|
+
delivered: [],
|
|
108
|
+
nextTaskNumber: 1,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const teamProjectionEntrySchema = z.object({
|
|
112
|
+
id: teamIdSchema,
|
|
113
|
+
members: z.array(teamMemberSnapshotSchema),
|
|
114
|
+
tasks: z.array(teamTaskSnapshotSchema),
|
|
115
|
+
messages: z.array(teamMessageSnapshotSchema),
|
|
116
|
+
delivered: z.array(teamMessageIdSchema),
|
|
117
|
+
nextTaskNumber: positiveSafeInteger,
|
|
118
|
+
failure: z.string().optional(),
|
|
119
|
+
}).strict();
|
|
120
|
+
/**
|
|
121
|
+
* Test whether a Session event belongs to the Team domain.
|
|
122
|
+
* @param event - candidate Session event.
|
|
123
|
+
* @returns whether the event has a Team-owned type.
|
|
124
|
+
*/
|
|
125
|
+
export function isTeamEvent(event) {
|
|
126
|
+
return event.type === 'team/member'
|
|
127
|
+
|| event.type === 'team/task'
|
|
128
|
+
|| event.type === 'team/message/queued'
|
|
129
|
+
|| event.type === 'team/message/delivered';
|
|
130
|
+
}
|
|
131
|
+
/** Decode one persisted Team value and retain the schema failure as its cause. */
|
|
132
|
+
function parsePersisted(type, schema, value) {
|
|
133
|
+
try {
|
|
134
|
+
return schema.parse(value);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
throw new Error(`persisted Agent Teams ${type} payload is invalid`, { cause: error });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Decode the complete current-version payload selected by one Team event type. */
|
|
141
|
+
function parseCurrentTeamEvent(event) {
|
|
142
|
+
switch (event.type) {
|
|
143
|
+
case 'team/member':
|
|
144
|
+
return { ...event, data: parsePersisted(event.type, teamMemberEventSchema, event.data) };
|
|
145
|
+
case 'team/task':
|
|
146
|
+
return { ...event, data: parsePersisted(event.type, teamTaskEventSchema, event.data) };
|
|
147
|
+
case 'team/message/queued':
|
|
148
|
+
return { ...event, data: parsePersisted(event.type, teamMessageQueuedEventSchema, event.data) };
|
|
149
|
+
case 'team/message/delivered':
|
|
150
|
+
return { ...event, data: parsePersisted(event.type, teamMessageDeliveredEventSchema, event.data) };
|
|
151
|
+
/* v8 ignore next 2 -- TeamEventType is closed and every member is handled above. */
|
|
152
|
+
default:
|
|
153
|
+
return event;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function applyProjectionEvent(state, event) {
|
|
157
|
+
if (state.failure !== undefined)
|
|
158
|
+
return;
|
|
159
|
+
if (!isTeamEvent(event))
|
|
160
|
+
return;
|
|
161
|
+
try {
|
|
162
|
+
const selector = parsePersisted(event.type, teamEventSelectorSchema, event.data);
|
|
163
|
+
if (selector.teamId !== state.id)
|
|
164
|
+
return;
|
|
165
|
+
if (selector.version !== 2) {
|
|
166
|
+
throw new Error(`unsupported Agent Teams event version ${String(selector.version)}`);
|
|
167
|
+
}
|
|
168
|
+
applyCurrentTeamEvent(state, parseCurrentTeamEvent(event));
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
/* v8 ignore next -- the owned Team transition throws Error instances. */
|
|
172
|
+
state.failure = error instanceof Error ? error.message : String(error);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function applyCurrentTeamEvent(state, event) {
|
|
176
|
+
switch (event.type) {
|
|
177
|
+
case 'team/member': {
|
|
178
|
+
const member = event.data.member;
|
|
179
|
+
const index = state.members.findIndex(candidate => candidate.id === member.id);
|
|
180
|
+
const prior = state.members[index];
|
|
181
|
+
const named = state.members.find(candidate => candidate.name === member.name);
|
|
182
|
+
if (named !== undefined && named.id !== member.id) {
|
|
183
|
+
throw new Error(`teammate name "${member.name}" is reused by another member`);
|
|
184
|
+
}
|
|
185
|
+
if (prior === undefined) {
|
|
186
|
+
if (member.phase !== 'provisioning')
|
|
187
|
+
throw new Error(`teammate "${member.name}" must begin provisioning`);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
if (prior.name !== member.name || prior.provider !== member.provider || prior.context !== member.context) {
|
|
191
|
+
throw new Error(`teammate "${member.id}" changed immutable identity fields`);
|
|
192
|
+
}
|
|
193
|
+
if (prior.phase !== 'provisioning' || member.phase === 'provisioning') {
|
|
194
|
+
throw new Error(`teammate "${member.name}" has an invalid ${prior.phase} -> ${member.phase} transition`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (index < 0)
|
|
198
|
+
state.members.push(member);
|
|
199
|
+
else
|
|
200
|
+
state.members[index] = member;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
case 'team/task': {
|
|
204
|
+
const task = event.data.task;
|
|
205
|
+
const index = state.tasks.findIndex(candidate => candidate.id === task.id);
|
|
206
|
+
const prior = state.tasks[index];
|
|
207
|
+
if (prior === undefined && task.revision !== 1) {
|
|
208
|
+
throw new Error(`team task "${task.id}" must begin at revision 1`);
|
|
209
|
+
}
|
|
210
|
+
if (prior !== undefined && task.revision !== prior.revision + 1) {
|
|
211
|
+
throw new Error(`team task "${task.id}" revision is not contiguous`);
|
|
212
|
+
}
|
|
213
|
+
assertTaskGraphCandidate(state.tasks, task);
|
|
214
|
+
const match = numericTaskIdPattern.exec(task.id);
|
|
215
|
+
if (match !== null) {
|
|
216
|
+
const number = Number(match[1]);
|
|
217
|
+
state.nextTaskNumber = Math.max(state.nextTaskNumber, number === Number.MAX_SAFE_INTEGER ? number : number + 1);
|
|
218
|
+
}
|
|
219
|
+
if (index < 0)
|
|
220
|
+
state.tasks.push(task);
|
|
221
|
+
else
|
|
222
|
+
state.tasks[index] = task;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
case 'team/message/queued': {
|
|
226
|
+
const message = event.data.message;
|
|
227
|
+
if (state.messages.some(candidate => candidate.id === message.id)) {
|
|
228
|
+
throw new Error(`team message "${message.id}" was queued twice`);
|
|
229
|
+
}
|
|
230
|
+
state.messages.push(message);
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
case 'team/message/delivered': {
|
|
234
|
+
const queued = state.messages.find(message => message.id === event.data.messageId);
|
|
235
|
+
if (queued === undefined)
|
|
236
|
+
throw new Error(`team message "${event.data.messageId}" was delivered before queueing`);
|
|
237
|
+
if (queued.targetId !== event.data.targetId)
|
|
238
|
+
throw new Error(`team message "${event.data.messageId}" target changed`);
|
|
239
|
+
if (state.delivered.includes(event.data.messageId))
|
|
240
|
+
throw new Error(`team message "${event.data.messageId}" was delivered twice`);
|
|
241
|
+
state.delivered.push(event.data.messageId);
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
/* v8 ignore next 2 -- TeamEventType is closed and every member is handled above. */
|
|
245
|
+
default:
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** Host-only Team projection selected by the projected Session identity. */
|
|
250
|
+
export const teamProjectionDefinition = {
|
|
251
|
+
key: 'agentTeam',
|
|
252
|
+
stateVersion: 3,
|
|
253
|
+
stateSchema: teamProjectionEntrySchema,
|
|
254
|
+
init: header => emptyTeamState(header.id),
|
|
255
|
+
apply: (state, event) => {
|
|
256
|
+
applyProjectionEvent(state, event);
|
|
257
|
+
return state;
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
//# sourceMappingURL=projection.js.map
|