@oai404iao/pi-subagent 0.3.0 → 0.4.0

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/src/mailbox.ts ADDED
@@ -0,0 +1,451 @@
1
+ import { createHash } from "node:crypto";
2
+ import { uuidv7 } from "@earendil-works/pi-ai";
3
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
4
+ import type { SessionView } from "./providers.ts";
5
+
6
+ export const MAILBOX_MESSAGE_CUSTOM_TYPE = "pi-subagent/mailbox-message";
7
+ export const MAILBOX_CLAIM_CUSTOM_TYPE = "pi-subagent/mailbox-claim";
8
+ export const MAILBOX_COMMIT_CUSTOM_TYPE = "pi-subagent/mailbox-commit";
9
+ export const MAILBOX_VERSION = 1;
10
+ export const MAX_MAILBOX_MESSAGE_CHARS = 128 * 1024;
11
+ export const MAX_PENDING_MAILBOX_MESSAGES = 256;
12
+ export const MAX_PENDING_MAILBOX_BYTES = 256 * 1024;
13
+
14
+ const UUID_V7_PATTERN =
15
+ /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
16
+
17
+ export interface MailboxMessage {
18
+ version: 1;
19
+ messageId: string;
20
+ senderAgentId: string;
21
+ recipientAgentId: string;
22
+ content: string;
23
+ createdAt: string;
24
+ }
25
+
26
+ export interface MailboxClaim {
27
+ version: 1;
28
+ turnId: string;
29
+ messageIds: string[];
30
+ claimedAt: string;
31
+ }
32
+
33
+ export interface MailboxCommit {
34
+ version: 1;
35
+ turnId: string;
36
+ userMessageDigest: string;
37
+ committedAt: string;
38
+ }
39
+
40
+ export interface MailboxSnapshot {
41
+ pending: MailboxMessage[];
42
+ claimedMessageIds: Set<string>;
43
+ }
44
+
45
+ export interface MailboxOwner {
46
+ parentAgentId: string;
47
+ agentId: string;
48
+ }
49
+
50
+ export type MailboxFold =
51
+ | { kind: "valid"; snapshot: MailboxSnapshot }
52
+ | { kind: "corrupt"; message: string };
53
+
54
+ type UnknownRecord = Record<string, unknown>;
55
+
56
+ function record(value: unknown, field: string): UnknownRecord {
57
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
58
+ throw new Error(`${field} must be an object`);
59
+ }
60
+ return value as UnknownRecord;
61
+ }
62
+
63
+ function uuid(value: unknown, field: string): string {
64
+ if (typeof value !== "string" || !UUID_V7_PATTERN.test(value)) {
65
+ throw new Error(`${field} must be a UUIDv7 id`);
66
+ }
67
+ return value;
68
+ }
69
+
70
+ function isoDate(value: unknown, field: string): string {
71
+ if (typeof value !== "string" || Number.isNaN(Date.parse(value))) {
72
+ throw new Error(`${field} must be an ISO date string`);
73
+ }
74
+ return value;
75
+ }
76
+
77
+ function parseMessage(value: unknown): MailboxMessage {
78
+ const input = record(value, "mailbox message");
79
+ if (input.version !== MAILBOX_VERSION) {
80
+ throw new Error(`unsupported mailbox message version: ${String(input.version)}`);
81
+ }
82
+ if (
83
+ typeof input.content !== "string"
84
+ || input.content.trim().length === 0
85
+ || input.content.length > MAX_MAILBOX_MESSAGE_CHARS
86
+ ) {
87
+ throw new Error(
88
+ `mailbox message content must be non-empty and at most ${MAX_MAILBOX_MESSAGE_CHARS} characters`,
89
+ );
90
+ }
91
+ return {
92
+ version: MAILBOX_VERSION,
93
+ messageId: uuid(input.messageId, "mailbox message.messageId"),
94
+ senderAgentId: uuid(input.senderAgentId, "mailbox message.senderAgentId"),
95
+ recipientAgentId: uuid(input.recipientAgentId, "mailbox message.recipientAgentId"),
96
+ content: input.content,
97
+ createdAt: isoDate(input.createdAt, "mailbox message.createdAt"),
98
+ };
99
+ }
100
+
101
+ function parseClaim(value: unknown): MailboxClaim {
102
+ const input = record(value, "mailbox claim");
103
+ if (input.version !== MAILBOX_VERSION) {
104
+ throw new Error(`unsupported mailbox claim version: ${String(input.version)}`);
105
+ }
106
+ if (
107
+ !Array.isArray(input.messageIds)
108
+ || input.messageIds.length === 0
109
+ ) {
110
+ throw new Error("mailbox claim.messageIds must be a non-empty array");
111
+ }
112
+ const messageIds = input.messageIds.map((value, index) =>
113
+ uuid(value, `mailbox claim.messageIds[${index}]`),
114
+ );
115
+ if (new Set(messageIds).size !== messageIds.length) {
116
+ throw new Error("mailbox claim.messageIds contains a duplicate id");
117
+ }
118
+ return {
119
+ version: MAILBOX_VERSION,
120
+ turnId: uuid(input.turnId, "mailbox claim.turnId"),
121
+ messageIds,
122
+ claimedAt: isoDate(input.claimedAt, "mailbox claim.claimedAt"),
123
+ };
124
+ }
125
+
126
+ function parseCommit(value: unknown): MailboxCommit {
127
+ const input = record(value, "mailbox commit");
128
+ if (input.version !== MAILBOX_VERSION) {
129
+ throw new Error(`unsupported mailbox commit version: ${String(input.version)}`);
130
+ }
131
+ return {
132
+ version: MAILBOX_VERSION,
133
+ turnId: uuid(input.turnId, "mailbox commit.turnId"),
134
+ userMessageDigest:
135
+ typeof input.userMessageDigest === "string"
136
+ && /^[0-9a-f]{64}$/i.test(input.userMessageDigest)
137
+ ? input.userMessageDigest.toLowerCase()
138
+ : (() => {
139
+ throw new Error(
140
+ "mailbox commit.userMessageDigest must be a SHA-256 digest",
141
+ );
142
+ })(),
143
+ committedAt: isoDate(input.committedAt, "mailbox commit.committedAt"),
144
+ };
145
+ }
146
+
147
+ function userMessageDigest(message: unknown): string | undefined {
148
+ if (
149
+ message === null
150
+ || typeof message !== "object"
151
+ || (message as { role?: unknown }).role !== "user"
152
+ ) {
153
+ return undefined;
154
+ }
155
+ const input = message as { content?: unknown; timestamp?: unknown };
156
+ return createHash("sha256")
157
+ .update(
158
+ JSON.stringify({
159
+ content: input.content,
160
+ timestamp: input.timestamp,
161
+ }),
162
+ )
163
+ .digest("hex");
164
+ }
165
+
166
+ function messageTurnId(entry: SessionEntry): string | undefined {
167
+ if (entry.type !== "message" || entry.message.role !== "user") return undefined;
168
+ const content = entry.message.content as unknown;
169
+ const parts = Array.isArray(content) ? content : [];
170
+ const text = parts
171
+ .filter(
172
+ (part): part is { type: "text"; text: string } =>
173
+ part !== null
174
+ && typeof part === "object"
175
+ && (part as { type?: unknown }).type === "text"
176
+ && typeof (part as { text?: unknown }).text === "string",
177
+ )
178
+ .map((part) => part.text)
179
+ .join("");
180
+ const match = /^\[\[pi-subagent-mailbox-turn:([0-9a-f-]+)\]\](?:\n|$)/i.exec(text);
181
+ if (!match || !UUID_V7_PATTERN.test(match[1]!)) return undefined;
182
+ return match[1];
183
+ }
184
+
185
+ function foldMailboxOrThrow(
186
+ entries: readonly SessionEntry[],
187
+ owner?: MailboxOwner,
188
+ ): MailboxSnapshot {
189
+ const pending: MailboxMessage[] = [];
190
+ const seenMessageIds = new Set<string>();
191
+ const claimedMessageIds = new Set<string>();
192
+ const promptIndexes = new Map<string, number>();
193
+ const commits = new Map<string, Array<{ index: number; digest: string }>>();
194
+ const userMessageIndexes = new Map<string, number[]>();
195
+ for (let index = 0; index < entries.length; index++) {
196
+ const entry = entries[index]!;
197
+ const turnId = messageTurnId(entry);
198
+ if (turnId !== undefined && !promptIndexes.has(turnId)) {
199
+ promptIndexes.set(turnId, index);
200
+ }
201
+ if (
202
+ entry.type === "custom"
203
+ && entry.customType === MAILBOX_COMMIT_CUSTOM_TYPE
204
+ ) {
205
+ const commit = parseCommit(entry.data);
206
+ const existing = commits.get(commit.turnId) ?? [];
207
+ existing.push({ index, digest: commit.userMessageDigest });
208
+ commits.set(commit.turnId, existing);
209
+ }
210
+ if (entry.type === "message" && entry.message.role === "user") {
211
+ const digest = userMessageDigest(entry.message);
212
+ if (digest) {
213
+ const existing = userMessageIndexes.get(digest) ?? [];
214
+ existing.push(index);
215
+ userMessageIndexes.set(digest, existing);
216
+ }
217
+ }
218
+ }
219
+ for (let entryIndex = 0; entryIndex < entries.length; entryIndex++) {
220
+ const entry = entries[entryIndex]!;
221
+ if (entry.type !== "custom") continue;
222
+ if (entry.customType === MAILBOX_MESSAGE_CUSTOM_TYPE) {
223
+ const message = parseMessage(entry.data);
224
+ if (
225
+ owner
226
+ && (
227
+ message.senderAgentId !== owner.parentAgentId
228
+ || message.recipientAgentId !== owner.agentId
229
+ )
230
+ ) {
231
+ throw new Error(
232
+ `mailbox message ${message.messageId} does not match its descriptor owner`,
233
+ );
234
+ }
235
+ if (seenMessageIds.has(message.messageId)) {
236
+ throw new Error(`duplicate mailbox message id: ${message.messageId}`);
237
+ }
238
+ seenMessageIds.add(message.messageId);
239
+ pending.push(message);
240
+ continue;
241
+ }
242
+ if (entry.customType !== MAILBOX_CLAIM_CUSTOM_TYPE) continue;
243
+ const claim = parseClaim(entry.data);
244
+ if (claim.messageIds.some((messageId) => !seenMessageIds.has(messageId))) {
245
+ throw new Error(`mailbox claim ${claim.turnId} references unavailable messages`);
246
+ }
247
+ const promptIndex = promptIndexes.get(claim.turnId);
248
+ const hasDurableCommit = (commits.get(claim.turnId) ?? []).some(
249
+ (commit) =>
250
+ commit.index > entryIndex
251
+ && (userMessageIndexes.get(commit.digest) ?? []).some(
252
+ (userIndex) => userIndex > commit.index,
253
+ ),
254
+ );
255
+ if (
256
+ (promptIndex === undefined || promptIndex <= entryIndex)
257
+ && !hasDurableCommit
258
+ ) {
259
+ // A crash or prompt rejection can leave a claim record without the
260
+ // corresponding durable user turn. Such reservations remain pending.
261
+ continue;
262
+ }
263
+ if (claim.messageIds.length > pending.length) {
264
+ throw new Error(`mailbox claim ${claim.turnId} references unavailable messages`);
265
+ }
266
+ for (let index = 0; index < claim.messageIds.length; index++) {
267
+ const messageId = claim.messageIds[index]!;
268
+ const expected = pending[index]?.messageId;
269
+ if (messageId !== expected) {
270
+ throw new Error(
271
+ `mailbox claim ${claim.turnId} is not the current FIFO prefix at index ${index}`,
272
+ );
273
+ }
274
+ if (claimedMessageIds.has(messageId)) {
275
+ throw new Error(`mailbox message ${messageId} was claimed more than once`);
276
+ }
277
+ claimedMessageIds.add(messageId);
278
+ }
279
+ pending.splice(0, claim.messageIds.length);
280
+ }
281
+ return {
282
+ pending: pending.map((message) => ({ ...message })),
283
+ claimedMessageIds,
284
+ };
285
+ }
286
+
287
+ export function foldMailbox(entries: readonly SessionEntry[]): MailboxFold {
288
+ try {
289
+ return { kind: "valid", snapshot: foldMailboxOrThrow(entries) };
290
+ } catch (error) {
291
+ return {
292
+ kind: "corrupt",
293
+ message: error instanceof Error ? error.message : String(error),
294
+ };
295
+ }
296
+ }
297
+
298
+ export function foldOwnedMailbox(
299
+ entries: readonly SessionEntry[],
300
+ owner: MailboxOwner,
301
+ ): MailboxFold {
302
+ try {
303
+ return {
304
+ kind: "valid",
305
+ snapshot: foldMailboxOrThrow(entries, owner),
306
+ };
307
+ } catch (error) {
308
+ return {
309
+ kind: "corrupt",
310
+ message: error instanceof Error ? error.message : String(error),
311
+ };
312
+ }
313
+ }
314
+
315
+ export function readMailbox(
316
+ entries: readonly SessionEntry[],
317
+ owner?: MailboxOwner,
318
+ ): MailboxSnapshot {
319
+ const folded = owner
320
+ ? foldOwnedMailbox(entries, owner)
321
+ : foldMailbox(entries);
322
+ if (folded.kind === "corrupt") {
323
+ throw new Error(`corrupt subagent mailbox: ${folded.message}`);
324
+ }
325
+ return folded.snapshot;
326
+ }
327
+
328
+ export function enqueueMailboxMessage(
329
+ session: SessionView,
330
+ input: {
331
+ senderAgentId: string;
332
+ recipientAgentId: string;
333
+ content: string;
334
+ },
335
+ ): { message: MailboxMessage; pendingMessages: number } {
336
+ const owner = {
337
+ parentAgentId: input.senderAgentId,
338
+ agentId: input.recipientAgentId,
339
+ };
340
+ const snapshot = readMailbox(session.getEntries(), owner);
341
+ const message = parseMessage({
342
+ version: MAILBOX_VERSION,
343
+ messageId: uuidv7(),
344
+ senderAgentId: input.senderAgentId,
345
+ recipientAgentId: input.recipientAgentId,
346
+ content: input.content,
347
+ createdAt: new Date().toISOString(),
348
+ });
349
+ if (snapshot.pending.length >= MAX_PENDING_MAILBOX_MESSAGES) {
350
+ throw new Error(
351
+ `subagent mailbox already contains ${MAX_PENDING_MAILBOX_MESSAGES} pending messages`,
352
+ );
353
+ }
354
+ const pendingBytes = snapshot.pending.reduce(
355
+ (total, pending) => total + Buffer.byteLength(pending.content, "utf8"),
356
+ 0,
357
+ );
358
+ if (
359
+ pendingBytes + Buffer.byteLength(message.content, "utf8")
360
+ > MAX_PENDING_MAILBOX_BYTES
361
+ ) {
362
+ throw new Error(
363
+ `subagent mailbox pending content exceeds ${MAX_PENDING_MAILBOX_BYTES} bytes`,
364
+ );
365
+ }
366
+ session.appendCustomEntry(MAILBOX_MESSAGE_CUSTOM_TYPE, message);
367
+ return {
368
+ message,
369
+ pendingMessages: snapshot.pending.length + 1,
370
+ };
371
+ }
372
+
373
+ export function claimMailboxMessages(
374
+ session: SessionView,
375
+ messageIds: readonly string[],
376
+ turnId: string,
377
+ owner?: MailboxOwner,
378
+ ): MailboxMessage[] {
379
+ const snapshot = readMailbox(session.getEntries(), owner);
380
+ if (messageIds.length === 0) throw new Error("cannot claim an empty mailbox batch");
381
+ if (messageIds.length > snapshot.pending.length) {
382
+ throw new Error("mailbox batch is no longer pending");
383
+ }
384
+ for (let index = 0; index < messageIds.length; index++) {
385
+ if (messageIds[index] !== snapshot.pending[index]?.messageId) {
386
+ throw new Error("mailbox batch is no longer the current FIFO prefix");
387
+ }
388
+ }
389
+ const claim = parseClaim({
390
+ version: MAILBOX_VERSION,
391
+ turnId,
392
+ messageIds: [...messageIds],
393
+ claimedAt: new Date().toISOString(),
394
+ });
395
+ session.appendCustomEntry(MAILBOX_CLAIM_CUSTOM_TYPE, claim);
396
+ return snapshot.pending
397
+ .slice(0, messageIds.length)
398
+ .map((message) => ({ ...message }));
399
+ }
400
+
401
+ export function commitMailboxClaim(
402
+ session: SessionView,
403
+ turnId: string,
404
+ userMessage: unknown,
405
+ ): void {
406
+ uuid(turnId, "mailbox commit.turnId");
407
+ const hasClaim = session.getEntries().some((entry) => {
408
+ if (
409
+ entry.type !== "custom"
410
+ || entry.customType !== MAILBOX_CLAIM_CUSTOM_TYPE
411
+ ) {
412
+ return false;
413
+ }
414
+ return parseClaim(entry.data).turnId === turnId;
415
+ });
416
+ if (!hasClaim) throw new Error(`mailbox turn ${turnId} has no claim to commit`);
417
+ const commit = parseCommit({
418
+ version: MAILBOX_VERSION,
419
+ turnId,
420
+ userMessageDigest:
421
+ userMessageDigest(userMessage)
422
+ ?? (() => {
423
+ throw new Error("mailbox claim can be committed only by a user message");
424
+ })(),
425
+ committedAt: new Date().toISOString(),
426
+ });
427
+ session.appendCustomEntry(MAILBOX_COMMIT_CUSTOM_TYPE, commit);
428
+ }
429
+
430
+ export function formatMailboxBatch(
431
+ messages: readonly MailboxMessage[],
432
+ turnId: string,
433
+ ): string {
434
+ if (messages.length === 0) throw new Error("cannot format an empty mailbox batch");
435
+ uuid(turnId, "mailbox batch turnId");
436
+ const records = messages.map((message) =>
437
+ JSON.stringify({
438
+ message_id: message.messageId,
439
+ sender_agent_id: message.senderAgentId,
440
+ content: message.content,
441
+ }),
442
+ );
443
+ return [
444
+ `[[pi-subagent-mailbox-turn:${turnId}]]`,
445
+ `Your direct parent queued ${messages.length} mailbox message${messages.length === 1 ? "" : "s"}.`,
446
+ "Process this claimed batch as one follow-up task, preserving FIFO order and addressing every message.",
447
+ "<mailbox_batch>",
448
+ ...records,
449
+ "</mailbox_batch>",
450
+ ].join("\n");
451
+ }