@qilin-agent/experimental-agent-team 3.1.3 → 3.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +60 -60
- package/README.md +12 -14
- package/README.zh.md +11 -13
- package/lib/index.js +925 -1145
- package/lib/typert.host.js +9 -9
- package/lib/typert.remote-client.js +2 -2
- package/lib/types/index.d.ts +12 -6
- package/lib/types/index.js +52 -18
- package/lib/types/lifecycle.d.ts +12 -0
- package/lib/types/lifecycle.js +18 -0
- package/lib/types/roster.d.ts +0 -6
- package/lib/types/roster.js +7 -18
- package/lib/types/types.d.ts +3 -6
- package/package.json +23 -23
- package/lib/types/mailbox.d.ts +0 -78
- package/lib/types/mailbox.js +0 -293
package/lib/types/mailbox.js
DELETED
|
@@ -1,293 +0,0 @@
|
|
|
1
|
-
/** Durable Team mailbox admission, target-local dispatch, acknowledgement, and recovery. */
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { brandString } from '@qilin-agent/brand';
|
|
4
|
-
import { createUserMessage } from '@qilin-agent/llm';
|
|
5
|
-
import { steerHostSubagentPrompt } from '@qilin-agent/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
|
-
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
|
|
268
|
-
const suffix = session.snapshotEvents(session.inheritedEventCount);
|
|
269
|
-
return messageAccepted(suffix, message => message.source.kind === 'team-message'
|
|
270
|
-
&& message.source.messageId === messageId);
|
|
271
|
-
}
|
|
272
|
-
/** Frame peer content with stable sender and message identity for the receiving model. */
|
|
273
|
-
deliveryContent(message) {
|
|
274
|
-
return [
|
|
275
|
-
{ type: 'text', text: `Team message ${message.id} from ${message.senderName}:` },
|
|
276
|
-
...structuredClone(message.content),
|
|
277
|
-
];
|
|
278
|
-
}
|
|
279
|
-
/** Read an inactive target's durable log before cold resume; uncertainty keeps the mailbox queued. */
|
|
280
|
-
async persistedTargetRecorded(targetId, messageId, signal) {
|
|
281
|
-
try {
|
|
282
|
-
const stored = await readPersistedSession(this.ctx.sessionPersistence, targetId, signal);
|
|
283
|
-
const suffix = stored.events.slice(stored.inheritedEventCount);
|
|
284
|
-
return messageAccepted(suffix, message => message.source.kind === 'team-message'
|
|
285
|
-
&& message.source.messageId === messageId);
|
|
286
|
-
}
|
|
287
|
-
catch (error) {
|
|
288
|
-
this.ctx.logger.warn(`cannot read Team message target "${targetId}": ${errorMessage(error)}`);
|
|
289
|
-
return undefined;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
//# sourceMappingURL=mailbox.js.map
|