pi-crew 0.9.8 → 0.9.9
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/CHANGELOG.md +33 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/extension/register.ts +94 -21
- package/src/extension/registration/subagent-helpers.ts +1 -0
- package/src/extension/registration/subagent-tools.ts +9 -0
- package/src/runtime/batch-barrier.ts +145 -0
- package/src/runtime/child-pi.ts +15 -2
- package/src/runtime/crash-classification.ts +208 -0
- package/src/runtime/custom-tools/irc-tool.ts +47 -7
- package/src/runtime/live-agent-manager.ts +185 -0
- package/src/runtime/process-lifecycle.ts +481 -0
- package/src/runtime/subagent-manager.ts +6 -0
- package/src/runtime/task-output-context.ts +52 -1
- package/src/runtime/tool-output-pruner.ts +334 -0
- package/src/state/types.ts +5 -0
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { defineTool, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { Type, type Static } from "@sinclair/typebox";
|
|
17
|
-
import { listLiveAgents, sendIrcMessage, broadcastIrcMessage } from "../live-agent-manager.ts";
|
|
17
|
+
import { listLiveAgents, sendIrcMessage, broadcastIrcMessage, respondAsBackground } from "../live-agent-manager.ts";
|
|
18
18
|
import type { IrcMessage } from "../live-irc.ts";
|
|
19
19
|
|
|
20
20
|
const IrcParams = Type.Object({
|
|
@@ -37,7 +37,7 @@ const IrcParams = Type.Object({
|
|
|
37
37
|
),
|
|
38
38
|
awaitReply: Type.Optional(
|
|
39
39
|
Type.Boolean({
|
|
40
|
-
description: "Wait for a reply (default: true for DM, false for broadcast).
|
|
40
|
+
description: "Wait for a prose reply (default: true for DM, false for broadcast). For DMs the recipient receives the message as a non-blocking background turn and its reply is returned to the caller. Broadcast always ignores this flag.",
|
|
41
41
|
}),
|
|
42
42
|
),
|
|
43
43
|
});
|
|
@@ -64,6 +64,8 @@ interface IrcDetails {
|
|
|
64
64
|
delivered?: string[];
|
|
65
65
|
notFound?: string[];
|
|
66
66
|
peers?: Array<{ id: string; status: string }>;
|
|
67
|
+
/** Replies received from recipients (awaitReply DM path). */
|
|
68
|
+
replies?: Array<{ from: string; text: string }>;
|
|
67
69
|
error?: string;
|
|
68
70
|
}
|
|
69
71
|
|
|
@@ -130,10 +132,10 @@ function executeList(selfId: string): { content: Array<{ type: "text"; text: str
|
|
|
130
132
|
};
|
|
131
133
|
}
|
|
132
134
|
|
|
133
|
-
function executeSend(
|
|
135
|
+
async function executeSend(
|
|
134
136
|
selfId: string,
|
|
135
137
|
params: IrcParams,
|
|
136
|
-
): { content: Array<{ type: "text"; text: string }>; details: IrcDetails } {
|
|
138
|
+
): Promise<{ content: Array<{ type: "text"; text: string }>; details: IrcDetails }> {
|
|
137
139
|
const to = params.to?.trim();
|
|
138
140
|
const message = params.message?.trim();
|
|
139
141
|
|
|
@@ -156,23 +158,52 @@ function executeSend(
|
|
|
156
158
|
};
|
|
157
159
|
}
|
|
158
160
|
|
|
161
|
+
// awaitReply defaults to true for DMs, false for broadcast. Broadcast
|
|
162
|
+
// always ignores the flag (fire-and-forget) — there is no single sender
|
|
163
|
+
// to receive a correlated reply from.
|
|
164
|
+
const isBroadcast = to === "all";
|
|
165
|
+
const wantsReply = !isBroadcast && (params.awaitReply ?? true);
|
|
166
|
+
|
|
159
167
|
const ircMessage: IrcMessage = {
|
|
160
168
|
from: selfId,
|
|
161
169
|
to,
|
|
162
170
|
content: message,
|
|
163
171
|
timestamp: new Date().toISOString(),
|
|
164
|
-
awaitReply:
|
|
172
|
+
awaitReply: wantsReply,
|
|
165
173
|
};
|
|
166
174
|
|
|
167
175
|
const notFound: string[] = [];
|
|
168
176
|
const delivered: string[] = [];
|
|
177
|
+
const replies: Array<{ from: string; text: string }> = [];
|
|
169
178
|
|
|
170
179
|
try {
|
|
171
|
-
if (
|
|
180
|
+
if (isBroadcast) {
|
|
181
|
+
// Broadcast: always fire-and-forget regardless of awaitReply.
|
|
172
182
|
const recipients = broadcastIrcMessage(selfId, ircMessage);
|
|
173
183
|
delivered.push(...recipients);
|
|
184
|
+
} else if (wantsReply) {
|
|
185
|
+
// DM with reply: use the non-blocking side-channel.
|
|
186
|
+
const agents = listLiveAgents();
|
|
187
|
+
const target = agents.find((a) => a.agentId === to);
|
|
188
|
+
if (!target || (target.status !== "running" && target.status !== "queued")) {
|
|
189
|
+
notFound.push(to);
|
|
190
|
+
} else {
|
|
191
|
+
const result = await respondAsBackground(to, selfId, message, { awaitReply: true });
|
|
192
|
+
if (result.ok) {
|
|
193
|
+
delivered.push(to);
|
|
194
|
+
if (result.replyContent) replies.push({ from: to, text: result.replyContent });
|
|
195
|
+
} else if (result.timedOut) {
|
|
196
|
+
// Message was delivered (non-blocking), but no reply in time.
|
|
197
|
+
delivered.push(to);
|
|
198
|
+
replies.push({ from: to, text: `(no reply — timed out)` });
|
|
199
|
+
} else {
|
|
200
|
+
// Delivery channel unavailable or cancelled.
|
|
201
|
+
if (result.error === "cancelled") delivered.push(to);
|
|
202
|
+
else notFound.push(to);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
174
205
|
} else {
|
|
175
|
-
// DM
|
|
206
|
+
// DM fire-and-forget (awaitReply explicitly false).
|
|
176
207
|
const agents = listLiveAgents();
|
|
177
208
|
const target = agents.find((a) => a.agentId === to);
|
|
178
209
|
if (!target || (target.status !== "running" && target.status !== "queued")) {
|
|
@@ -197,6 +228,14 @@ function executeSend(
|
|
|
197
228
|
} else {
|
|
198
229
|
lines.push("No recipients received the message.");
|
|
199
230
|
}
|
|
231
|
+
if (replies.length > 0) {
|
|
232
|
+
lines.push("");
|
|
233
|
+
lines.push("## Replies");
|
|
234
|
+
for (const reply of replies) {
|
|
235
|
+
lines.push(`### ${reply.from}`);
|
|
236
|
+
lines.push(reply.text);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
200
239
|
if (notFound.length > 0) {
|
|
201
240
|
lines.push(`Unknown / unavailable peers: ${notFound.join(", ")}`);
|
|
202
241
|
}
|
|
@@ -209,6 +248,7 @@ function executeSend(
|
|
|
209
248
|
to,
|
|
210
249
|
delivered: delivered.length > 0 ? delivered : undefined,
|
|
211
250
|
notFound: notFound.length > 0 ? notFound : undefined,
|
|
251
|
+
replies: replies.length > 0 ? replies : undefined,
|
|
212
252
|
},
|
|
213
253
|
};
|
|
214
254
|
}
|
|
@@ -416,3 +416,188 @@ function drainIrcMessages(agentIdOrTaskId: string): IrcMessage[] {
|
|
|
416
416
|
handle.pendingMessages.length = 0;
|
|
417
417
|
return messages;
|
|
418
418
|
}
|
|
419
|
+
|
|
420
|
+
/* ── IRC reply support (side-channel Q&A) ─────────────────────────── */
|
|
421
|
+
|
|
422
|
+
/** Default timeout for awaiting a side-channel reply (60s). */
|
|
423
|
+
const DEFAULT_REPLY_TIMEOUT_MS = 60_000;
|
|
424
|
+
|
|
425
|
+
/** Result of a background reply attempt. */
|
|
426
|
+
export interface BackgroundReplyResult {
|
|
427
|
+
ok: boolean;
|
|
428
|
+
/** Correlation id for the pending reply (present once registered). */
|
|
429
|
+
corrId?: string;
|
|
430
|
+
/** Reply prose content (present on success when awaitReply was set). */
|
|
431
|
+
replyContent?: string;
|
|
432
|
+
/** Human-readable error description. */
|
|
433
|
+
error?: string;
|
|
434
|
+
/** True when the reply did not arrive before the timeout. */
|
|
435
|
+
timedOut?: boolean;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
interface PendingReply {
|
|
439
|
+
corrId: string;
|
|
440
|
+
targetAgentId: string;
|
|
441
|
+
fromId: string;
|
|
442
|
+
deadline: number;
|
|
443
|
+
resolve: (result: BackgroundReplyResult) => void;
|
|
444
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** In-process pending replies keyed by correlation id. */
|
|
448
|
+
const pendingReplies = new Map<string, PendingReply>();
|
|
449
|
+
/** Reverse index: targetAgentId → set of corrIds awaiting a reply from it. */
|
|
450
|
+
const pendingRepliesByTarget = new Map<string, Set<string>>();
|
|
451
|
+
|
|
452
|
+
function makeCorrelationId(): string {
|
|
453
|
+
return `irc_reply_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Deliver a message to a live agent's session as a *background* turn —
|
|
458
|
+
* without blocking the recipient's main agent loop — and (optionally)
|
|
459
|
+
* await a prose reply via a side-channel.
|
|
460
|
+
*
|
|
461
|
+
* Non-blocking invariant (mirrors gajae-code's `respondAsBackground`):
|
|
462
|
+
* the message is injected via `sendCustomMessage` (triggerTurn:false) or a
|
|
463
|
+
* fire-and-forget `session.prompt`; we NEVER await the recipient's full
|
|
464
|
+
* main-loop turn. When `awaitReply` is set we instead await an event-driven
|
|
465
|
+
* reply resolution (see {@link resolveIrcReply}) bounded by a timeout.
|
|
466
|
+
*
|
|
467
|
+
* Note on mailbox.ts reply fields: those file-based fields
|
|
468
|
+
* (`replyTo`/`replyContent`/`replyDeadline`/`updateMailboxMessageReply`)
|
|
469
|
+
* serve cross-process workers that communicate via on-disk mailbox files.
|
|
470
|
+
* Live-session agents share a single process, so an in-memory event-driven
|
|
471
|
+
* registry is used here — it is lower-latency and trivially non-blocking.
|
|
472
|
+
* Both mechanisms coexist; file-based workers keep using mailbox.ts.
|
|
473
|
+
*/
|
|
474
|
+
export async function respondAsBackground(
|
|
475
|
+
targetAgentId: string,
|
|
476
|
+
fromId: string,
|
|
477
|
+
message: string,
|
|
478
|
+
opts?: { awaitReply?: boolean; timeoutMs?: number; signal?: AbortSignal },
|
|
479
|
+
): Promise<BackgroundReplyResult> {
|
|
480
|
+
const handle = getLiveAgent(targetAgentId);
|
|
481
|
+
if (!handle) return { ok: false, error: `Live agent '${targetAgentId}' not found.` };
|
|
482
|
+
|
|
483
|
+
const awaitReply = opts?.awaitReply ?? false;
|
|
484
|
+
const timeoutMs = opts?.timeoutMs ?? DEFAULT_REPLY_TIMEOUT_MS;
|
|
485
|
+
const corrId = makeCorrelationId();
|
|
486
|
+
|
|
487
|
+
// --- Non-blocking delivery -------------------------------------------
|
|
488
|
+
const session = handle.session as Record<string, unknown>;
|
|
489
|
+
const deliveredTag = `[DM from ${fromId}] ${message}`;
|
|
490
|
+
let delivered = false;
|
|
491
|
+
if (typeof session.sendCustomMessage === "function") {
|
|
492
|
+
try {
|
|
493
|
+
(session.sendCustomMessage as (msg: unknown, o?: unknown) => void)(
|
|
494
|
+
{ customType: "irc", content: deliveredTag, display: "collapsed", corrId },
|
|
495
|
+
{ deliverAs: "followUp", triggerTurn: false },
|
|
496
|
+
);
|
|
497
|
+
delivered = true;
|
|
498
|
+
} catch {
|
|
499
|
+
// fall through to prompt-based delivery
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (!delivered && typeof handle.session.prompt === "function") {
|
|
503
|
+
const promptText = `${deliveredTag}${awaitReply ? ` (reply correlation: ${corrId})` : ""}`;
|
|
504
|
+
void handle.session.prompt(promptText, { source: "api", expandPromptTemplates: false }).catch((error) => logInternalError("live-agent-manager.respondAsBackground", error, `agentId=${handle.agentId}`));
|
|
505
|
+
delivered = true;
|
|
506
|
+
}
|
|
507
|
+
if (!delivered) return { ok: false, error: `Target '${targetAgentId}' has no message channel.` };
|
|
508
|
+
handle.updatedAt = new Date().toISOString();
|
|
509
|
+
|
|
510
|
+
if (!awaitReply) return { ok: true, corrId };
|
|
511
|
+
|
|
512
|
+
// --- Await reply (event-driven, bounded by timeout) ------------------
|
|
513
|
+
return awaitPendingReply(corrId, targetAgentId, fromId, timeoutMs, opts?.signal);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Register a pending reply and resolve it when the reply arrives, the
|
|
518
|
+
* timeout elapses, or the caller's abort signal fires.
|
|
519
|
+
*
|
|
520
|
+
* @internal exported for testing
|
|
521
|
+
*/
|
|
522
|
+
export function awaitPendingReply(
|
|
523
|
+
corrId: string,
|
|
524
|
+
targetAgentId: string,
|
|
525
|
+
fromId: string,
|
|
526
|
+
timeoutMs: number,
|
|
527
|
+
signal?: AbortSignal,
|
|
528
|
+
): Promise<BackgroundReplyResult> {
|
|
529
|
+
return new Promise((resolve) => {
|
|
530
|
+
const deadline = Date.now() + timeoutMs;
|
|
531
|
+
let settled = false;
|
|
532
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
533
|
+
let signalListener: (() => void) | undefined;
|
|
534
|
+
|
|
535
|
+
const finish = (result: BackgroundReplyResult) => {
|
|
536
|
+
if (settled) return;
|
|
537
|
+
settled = true;
|
|
538
|
+
if (timer) clearTimeout(timer);
|
|
539
|
+
if (signalListener && signal) signal.removeEventListener("abort", signalListener);
|
|
540
|
+
pendingReplies.delete(corrId);
|
|
541
|
+
const set = pendingRepliesByTarget.get(targetAgentId);
|
|
542
|
+
set?.delete(corrId);
|
|
543
|
+
if (set && set.size === 0) pendingRepliesByTarget.delete(targetAgentId);
|
|
544
|
+
resolve(result);
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
timer = setTimeout(() => finish({ ok: false, corrId, timedOut: true }), timeoutMs);
|
|
548
|
+
|
|
549
|
+
if (signal) {
|
|
550
|
+
if (signal.aborted) {
|
|
551
|
+
finish({ ok: false, corrId, error: "cancelled" });
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
signalListener = () => finish({ ok: false, corrId, error: "cancelled" });
|
|
555
|
+
signal.addEventListener("abort", signalListener, { once: true });
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
pendingReplies.set(corrId, { corrId, targetAgentId, fromId, deadline, resolve: finish, timer });
|
|
559
|
+
const set = pendingRepliesByTarget.get(targetAgentId) ?? new Set<string>();
|
|
560
|
+
set.add(corrId);
|
|
561
|
+
pendingRepliesByTarget.set(targetAgentId, set);
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Resolve a pending side-channel reply. Called by the reply-routing layer
|
|
567
|
+
* (e.g. irc-tool when the recipient sends a message back referencing the
|
|
568
|
+
* correlation id, or by tests simulating a recipient response).
|
|
569
|
+
*
|
|
570
|
+
* Returns true if a pending reply was resolved, false if none matched
|
|
571
|
+
* (already timed out / cancelled / unknown correlation id).
|
|
572
|
+
*/
|
|
573
|
+
export function resolveIrcReply(corrId: string, replyContent: string): boolean {
|
|
574
|
+
const pending = pendingReplies.get(corrId);
|
|
575
|
+
if (!pending) return false;
|
|
576
|
+
pending.resolve({ ok: true, corrId, replyContent });
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Cancel a pending side-channel reply (e.g. sender gave up).
|
|
582
|
+
* Returns true if a pending reply was cancelled, false if none matched.
|
|
583
|
+
*/
|
|
584
|
+
export function cancelIrcReply(corrId: string, reason = "cancelled"): boolean {
|
|
585
|
+
const pending = pendingReplies.get(corrId);
|
|
586
|
+
if (!pending) return false;
|
|
587
|
+
pending.resolve({ ok: false, corrId, error: reason });
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Correlation ids currently awaiting a reply from the given target agent. */
|
|
592
|
+
export function pendingReplyCorrIdsForTarget(targetAgentId: string): string[] {
|
|
593
|
+
return [...(pendingRepliesByTarget.get(targetAgentId) ?? [])];
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Clear all pending replies (test helper). */
|
|
597
|
+
export function clearPendingRepliesForTest(): void {
|
|
598
|
+
for (const pending of pendingReplies.values()) {
|
|
599
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
600
|
+
}
|
|
601
|
+
pendingReplies.clear();
|
|
602
|
+
pendingRepliesByTarget.clear();
|
|
603
|
+
}
|