@qilin-agent/experimental-agent-team 0.0.0-stage → 3.1.3

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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +103 -0
  3. package/README.md +227 -2
  4. package/README.zh.md +228 -0
  5. package/lib/index.js +2002 -0
  6. package/lib/typert.host.d.ts +3 -0
  7. package/lib/typert.host.js +875 -0
  8. package/lib/typert.remote-client.d.ts +30 -0
  9. package/lib/typert.remote-client.js +141 -0
  10. package/lib/types/activity.d.ts +23 -0
  11. package/lib/types/activity.js +85 -0
  12. package/lib/types/client.d.ts +8 -0
  13. package/lib/types/client.js +3 -0
  14. package/lib/types/error.d.ts +13 -0
  15. package/lib/types/error.js +23 -0
  16. package/lib/types/index.d.ts +142 -0
  17. package/lib/types/index.js +306 -0
  18. package/lib/types/journal.d.ts +39 -0
  19. package/lib/types/journal.js +63 -0
  20. package/lib/types/lifecycle.d.ts +33 -0
  21. package/lib/types/lifecycle.js +85 -0
  22. package/lib/types/mailbox.d.ts +78 -0
  23. package/lib/types/mailbox.js +293 -0
  24. package/lib/types/persisted.d.ts +20 -0
  25. package/lib/types/persisted.js +20 -0
  26. package/lib/types/projection.d.ts +66 -0
  27. package/lib/types/projection.js +327 -0
  28. package/lib/types/remote.d.ts +31 -0
  29. package/lib/types/remote.js +22 -0
  30. package/lib/types/roster.d.ts +113 -0
  31. package/lib/types/roster.js +452 -0
  32. package/lib/types/session-message.d.ts +11 -0
  33. package/lib/types/session-message.js +23 -0
  34. package/lib/types/task-board.d.ts +53 -0
  35. package/lib/types/task-board.js +235 -0
  36. package/lib/types/task-graph.d.ts +21 -0
  37. package/lib/types/task-graph.js +62 -0
  38. package/lib/types/task-view.d.ts +21 -0
  39. package/lib/types/task-view.js +57 -0
  40. package/lib/types/types.d.ts +215 -0
  41. package/lib/types/types.js +26 -0
  42. package/lib/types/validation.d.ts +16 -0
  43. package/lib/types/validation.js +33 -0
  44. package/locale/en.json +6 -0
  45. package/locale/zh.json +6 -0
  46. package/package.json +83 -3
@@ -0,0 +1,293 @@
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
@@ -0,0 +1,20 @@
1
+ /** Short-lived read-handle access to persisted Team member Sessions. */
2
+ import type { SessionEvent, SessionHeader, SessionId, SessionLogOffset } from '@qilin-agent/session';
3
+ import type { SessionPersistence } from '@qilin-agent/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,66 @@
1
+ /** Team state projected incrementally from committed Session events, with a durable-only client view. */
2
+ import { z } from 'zod';
3
+ import type { SessionEvent, SessionId } from '@qilin-agent/session';
4
+ import type { TeamId, TeamMemberSnapshot, TeamMessageId, TeamMessageSnapshot, TeamProjection, TeamTaskSnapshot } from './types.ts';
5
+ /**
6
+ * Current Team state selected by durable Team identity. Every applied Team
7
+ * event produces a new state object and replaces only the collection it
8
+ * touched; untouched collections keep their references.
9
+ */
10
+ export interface TeamState {
11
+ readonly id: TeamId;
12
+ readonly members: readonly TeamMemberSnapshot[];
13
+ readonly tasks: readonly TeamTaskSnapshot[];
14
+ readonly messages: readonly TeamMessageSnapshot[];
15
+ readonly delivered: readonly TeamMessageId[];
16
+ readonly nextTaskNumber: number;
17
+ }
18
+ /**
19
+ * Construct empty state for one Team identity.
20
+ * @param rootId - root Session identity.
21
+ * @returns empty Team state.
22
+ */
23
+ export declare function emptyTeamState(rootId: SessionId): TeamProjectionState;
24
+ /** Checkpoint-safe state for the Team owned by the projected Session. */
25
+ export interface TeamProjectionState extends TeamState {
26
+ readonly failure?: string;
27
+ }
28
+ declare module '@qilin-agent/session-projection/types' {
29
+ interface SessionProjectionStateMap {
30
+ agentTeam: TeamProjectionState;
31
+ }
32
+ }
33
+ /** Whether one event belongs to the Team domain. */
34
+ export type TeamEventType = 'team/member' | 'team/task' | 'team/message/queued' | 'team/message/delivered';
35
+ /** One event owned by the Team domain. */
36
+ type TeamSessionEvent = SessionEvent<TeamEventType>;
37
+ /**
38
+ * Test whether a Session event belongs to the Team domain.
39
+ * @param event - candidate Session event.
40
+ * @returns whether the event has a Team-owned type.
41
+ */
42
+ export declare function isTeamEvent(event: SessionEvent): event is TeamSessionEvent;
43
+ declare function applyProjectionEvent(state: TeamProjectionState, event: SessionEvent): TeamProjectionState;
44
+ /**
45
+ * Durable client view of one Team state. Mailbox-only state changes reuse the
46
+ * previous view reference, so the live drive publishes nothing for them.
47
+ * A failure is terminal: later events retain the failed state reference and
48
+ * do not republish its view.
49
+ * @param state - current Team state.
50
+ * @returns the roster and non-deleted task board, plus any projection failure.
51
+ */
52
+ export declare function teamProjectionView(state: TeamProjectionState): TeamProjection;
53
+ /** Team projection selected by the projected Session identity; the wire view carries durable roster and task state only. */
54
+ export declare const teamProjectionDefinition: {
55
+ key: "agentTeam";
56
+ stateVersion: number;
57
+ stateSchema: z.ZodType<TeamProjectionState, unknown, z.core.$ZodTypeInternals<TeamProjectionState, unknown>>;
58
+ init: (header: import("@qilin-agent/session").SessionHeader) => TeamProjectionState;
59
+ apply: typeof applyProjectionEvent;
60
+ wire: {
61
+ viewSchema: z.ZodType<TeamProjection, unknown, z.core.$ZodTypeInternals<TeamProjection, unknown>>;
62
+ view: typeof teamProjectionView;
63
+ };
64
+ };
65
+ export {};
66
+ //# sourceMappingURL=projection.d.ts.map