@ian-pascoe/pi-minimal-subagents 0.12.0 → 0.14.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/README.md +115 -26
- package/package.json +2 -2
- package/src/minimal-subagents-capabilities.ts +12 -1
- package/src/minimal-subagents-config.ts +11 -6
- package/src/minimal-subagents-context.ts +180 -4
- package/src/minimal-subagents-coordinator.ts +303 -32
- package/src/minimal-subagents-delivery-ledger.ts +14 -13
- package/src/minimal-subagents-extension.ts +62 -2
- package/src/minimal-subagents-registry-wire.ts +1 -0
- package/src/minimal-subagents-registry.ts +6 -1
- package/src/minimal-subagents-render-contract.ts +33 -2
- package/src/minimal-subagents-rendering.ts +321 -52
- package/src/minimal-subagents-sessions.ts +3 -1
- package/src/minimal-subagents-status-panel.ts +26 -203
- package/src/minimal-subagents-tool-schemas.ts +20 -3
- package/src/minimal-subagents-tools.ts +109 -35
- package/src/minimal-subagents-transcript.ts +296 -0
- package/src/minimal-subagents-types.ts +28 -5
- package/src/minimal-subagents-ui.ts +1 -1
- package/src/minimal-subagents-usage.ts +13 -2
|
@@ -2,10 +2,14 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import {
|
|
3
3
|
assembleImportedContext,
|
|
4
4
|
boundRecentAgentText,
|
|
5
|
+
buildActiveTurnProgress,
|
|
6
|
+
buildInheritedContextTaskPrompt,
|
|
5
7
|
buildRecentAgentActivity,
|
|
6
8
|
contextContainsImages,
|
|
9
|
+
selectActiveTurnTranscript,
|
|
7
10
|
selectChildAgentTranscript,
|
|
8
11
|
} from "./minimal-subagents-context.js";
|
|
12
|
+
|
|
9
13
|
import {
|
|
10
14
|
canAgentContractSpawn,
|
|
11
15
|
DEFAULT_MAX_SUBAGENT_DEPTH,
|
|
@@ -19,6 +23,7 @@ import {
|
|
|
19
23
|
claimDeliveryLedgerTurn,
|
|
20
24
|
createDeliveryLedger,
|
|
21
25
|
deliveryLedgerSnapshot,
|
|
26
|
+
deliveryTurnKey,
|
|
22
27
|
findCoordinationDelivery,
|
|
23
28
|
findTerminalDelivery,
|
|
24
29
|
isDeliveryLedgerTurnClaimed,
|
|
@@ -38,7 +43,9 @@ import { withTroubleshootingHint } from "./troubleshooting-skill.js";
|
|
|
38
43
|
import { addCoordinatorMessageEnvelope } from "./minimal-subagents-message-envelope.js";
|
|
39
44
|
import { unavailableAgent } from "./minimal-subagents-sessions.js";
|
|
40
45
|
import { createRegistryEvent } from "./minimal-subagents-registry.js";
|
|
46
|
+
import type { MinimalSubagentsModelRole } from "./minimal-subagents-config.js";
|
|
41
47
|
import type {
|
|
48
|
+
ActiveTurnProgress,
|
|
42
49
|
AgentDetail,
|
|
43
50
|
AgentMessageDisposition,
|
|
44
51
|
AgentMessageResult,
|
|
@@ -70,6 +77,16 @@ const RESERVED_AGENT_IDS = new Set(["root", "parent"]);
|
|
|
70
77
|
const RECENT_MESSAGE_LIMIT = 20;
|
|
71
78
|
const DEFAULT_AUTOMATIC_DELIVERY_GRACE_MS = 1_000;
|
|
72
79
|
|
|
80
|
+
/** Assistant messages a waiting parent's live view renders from the child's running turn. */
|
|
81
|
+
const ACTIVE_TURN_TRANSCRIPT_ASSISTANT_MESSAGES = 3;
|
|
82
|
+
|
|
83
|
+
/** A direct child's running turn as its waiting parent may observe it. */
|
|
84
|
+
interface ObservedActiveTurn {
|
|
85
|
+
turnId: string;
|
|
86
|
+
startedAtMs: number;
|
|
87
|
+
runtime: ChildAgentRuntime;
|
|
88
|
+
}
|
|
89
|
+
|
|
73
90
|
interface MessageParameters {
|
|
74
91
|
agent_id?: string;
|
|
75
92
|
message: string;
|
|
@@ -94,6 +111,16 @@ interface PendingParentMessage {
|
|
|
94
111
|
cancelGrace?: () => void;
|
|
95
112
|
}
|
|
96
113
|
|
|
114
|
+
/** One terminal result queued to its parent, until Delivery Evidence settles it or Pi discards it. */
|
|
115
|
+
interface TerminalHandOff {
|
|
116
|
+
/** Hand-off order, compared with the root's last ended message at a turn boundary. */
|
|
117
|
+
order: number;
|
|
118
|
+
/** Whether the result entered Pi's clearable steer queue instead of starting a root turn. */
|
|
119
|
+
queued: boolean;
|
|
120
|
+
/** Coordination Messages batched into the same steer. */
|
|
121
|
+
coordinationDeliveryIds: readonly string[];
|
|
122
|
+
}
|
|
123
|
+
|
|
97
124
|
function agentDeliveryKey(agentId: string, turnId: string): string {
|
|
98
125
|
return `${agentId}\u0000${turnId}`;
|
|
99
126
|
}
|
|
@@ -147,9 +174,18 @@ export class MinimalSubagentsCoordinator {
|
|
|
147
174
|
private readonly waiters = new Map<string, Set<TurnWaiter>>();
|
|
148
175
|
private readonly pendingParentMessages = new Map<string, PendingParentMessage[]>();
|
|
149
176
|
private readonly recipientQueues = new Map<string, Promise<unknown>>();
|
|
150
|
-
|
|
177
|
+
/** In-flight automatic result hand-offs, each with the lifecycle epoch that owns it. */
|
|
178
|
+
private readonly automaticDeliveryKeys = new Map<string, number>();
|
|
151
179
|
private readonly automaticCoordinationDeliveryIds = new Set<string>();
|
|
152
180
|
private readonly waitHandedDeliveryIds = new Set<string>();
|
|
181
|
+
/** Terminal results already queued to their parent, until Delivery Evidence settles them. */
|
|
182
|
+
private readonly handedTerminalKeys = new Map<string, TerminalHandOff>();
|
|
183
|
+
/** Monotonic order of hand-offs, compared against the root's last ended message. */
|
|
184
|
+
private handOffOrder = 0;
|
|
185
|
+
/** The latest hand-off order a root `message_end` has seen; later hand-offs may postdate Pi's queue snapshot. */
|
|
186
|
+
private rootMessageEndOrder = 0;
|
|
187
|
+
/** Root results Pi discarded, awaiting the root's next run; each keeps its Delivery Ledger sequence. */
|
|
188
|
+
private readonly discardedRootResultKeys = new Set<string>();
|
|
153
189
|
private readonly backgroundOperations = new Set<Promise<void>>();
|
|
154
190
|
private acceptingOperations = true;
|
|
155
191
|
private lifecycleEpoch = 0;
|
|
@@ -197,16 +233,20 @@ export class MinimalSubagentsCoordinator {
|
|
|
197
233
|
this.validateFriendlyId(friendlyId);
|
|
198
234
|
const agentId = this.buildChildAgentId(callerId, friendlyId);
|
|
199
235
|
if (this.tombstones.has(agentId)) {
|
|
200
|
-
throw new Error(
|
|
236
|
+
throw new Error(
|
|
237
|
+
`Minimal subagents agent ID ${agentId} belonged to a deleted agent and cannot be reused; choose a different agent_id`,
|
|
238
|
+
);
|
|
201
239
|
}
|
|
202
240
|
if (this.agents.has(agentId) || this.pendingAgentIds.has(agentId)) {
|
|
203
241
|
throw new Error(`Minimal subagents duplicate agent ID: ${agentId}`);
|
|
204
242
|
}
|
|
205
243
|
|
|
206
|
-
const sessionContext = parameters.session_context ?? "
|
|
244
|
+
const sessionContext = parameters.session_context ?? "omit";
|
|
207
245
|
const projectContext = parameters.project_context ?? "inherit";
|
|
208
|
-
const
|
|
209
|
-
const
|
|
246
|
+
const role = parameters.role === undefined ? undefined : this.requireModelRole(parameters.role);
|
|
247
|
+
const model = parameters.model ?? role?.model ?? caller.model;
|
|
248
|
+
const requestedThinking =
|
|
249
|
+
parameters.thinking_level ?? role?.thinkingLevel ?? caller.thinkingLevel;
|
|
210
250
|
const thinkingLevel = this.dependencies.sessions.resolveThinkingLevel(model, requestedThinking);
|
|
211
251
|
const {
|
|
212
252
|
ordinaryTools,
|
|
@@ -218,7 +258,7 @@ export class MinimalSubagentsCoordinator {
|
|
|
218
258
|
toolsets: this.dependencies.toolsets,
|
|
219
259
|
});
|
|
220
260
|
const committedMessages = structuredClone(caller.messages);
|
|
221
|
-
const imported = assembleImportedContext(sessionContext, committedMessages);
|
|
261
|
+
const imported = assembleImportedContext(sessionContext, committedMessages, callerId);
|
|
222
262
|
if (
|
|
223
263
|
contextContainsImages(imported.messages) &&
|
|
224
264
|
!this.dependencies.sessions.modelSupportsImages(model)
|
|
@@ -251,6 +291,8 @@ export class MinimalSubagentsCoordinator {
|
|
|
251
291
|
missing_dependencies: [],
|
|
252
292
|
recent_messages: [],
|
|
253
293
|
};
|
|
294
|
+
// Added after `delegation`, matching the key order of replayed Launch Contracts.
|
|
295
|
+
if (role) agent.launch_contract.role = role.name;
|
|
254
296
|
this.pendingAgentIds.add(agentId);
|
|
255
297
|
let identity: ReturnType<AgentSessionFactory["createIdentity"]>;
|
|
256
298
|
try {
|
|
@@ -296,11 +338,12 @@ export class MinimalSubagentsCoordinator {
|
|
|
296
338
|
createRegistryEvent(this.dependencies.registry.rootSessionId, "agent-created", { agent }),
|
|
297
339
|
);
|
|
298
340
|
const turnId = this.beginTurn(agent);
|
|
299
|
-
|
|
341
|
+
const warnings = [...new Set(toolWarnings)];
|
|
342
|
+
if (warnings.length > 0) {
|
|
300
343
|
this.dependencies.notify?.({
|
|
301
344
|
type: "tool-warning",
|
|
302
345
|
agentId,
|
|
303
|
-
message: `Minimal subagents tool warnings for ${agentId}:\n- ${
|
|
346
|
+
message: `Minimal subagents tool warnings for ${agentId}:\n- ${warnings.join("\n- ")}`,
|
|
304
347
|
});
|
|
305
348
|
}
|
|
306
349
|
this.dependencies.notify?.({
|
|
@@ -313,13 +356,38 @@ export class MinimalSubagentsCoordinator {
|
|
|
313
356
|
this.initializeAndRunPrompt(
|
|
314
357
|
agentId,
|
|
315
358
|
turnId,
|
|
316
|
-
|
|
359
|
+
imported.messages.length > 0
|
|
360
|
+
? buildInheritedContextTaskPrompt(parameters.task, agentId, callerId)
|
|
361
|
+
: parameters.task,
|
|
317
362
|
imported.compact,
|
|
318
363
|
caller.model,
|
|
319
364
|
caller.thinkingLevel,
|
|
320
365
|
),
|
|
321
366
|
);
|
|
322
|
-
|
|
367
|
+
const result: SpawnResult = {
|
|
368
|
+
agent_id: agentId,
|
|
369
|
+
turn_id: turnId,
|
|
370
|
+
status: "running",
|
|
371
|
+
model: agent.launch_contract.model,
|
|
372
|
+
thinking_level: agent.launch_contract.thinking_level,
|
|
373
|
+
tools: [...agent.launch_contract.ordinary_tools],
|
|
374
|
+
delegation: agent.launch_contract.delegation ?? "none",
|
|
375
|
+
};
|
|
376
|
+
if (warnings.length > 0) result.warnings = warnings;
|
|
377
|
+
return result;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private requireModelRole(name: string): MinimalSubagentsModelRole {
|
|
381
|
+
const roles = this.dependencies.modelRoles ?? [];
|
|
382
|
+
const role = roles.find((candidate) => candidate.name === name);
|
|
383
|
+
if (role) return role;
|
|
384
|
+
throw new Error(
|
|
385
|
+
`Minimal subagents spawn validation: unknown role ${JSON.stringify(name)}; ${
|
|
386
|
+
roles.length === 0
|
|
387
|
+
? "no model roles are configured"
|
|
388
|
+
: `configured roles: ${roles.map((candidate) => candidate.name).join(", ")}`
|
|
389
|
+
}`,
|
|
390
|
+
);
|
|
323
391
|
}
|
|
324
392
|
|
|
325
393
|
/** Capture immutable launch defaults for a nested caller from its active child runtime. */
|
|
@@ -365,10 +433,12 @@ export class MinimalSubagentsCoordinator {
|
|
|
365
433
|
this.queuePendingParentMessage(targetId, message, delivery);
|
|
366
434
|
return { agent_id: targetId, message_id: messageId, disposition: "queued" };
|
|
367
435
|
}
|
|
368
|
-
const disposition = await this.enqueueRecipientDelivery(targetId, async () =>
|
|
436
|
+
const { disposition, turnId } = await this.enqueueRecipientDelivery(targetId, async () =>
|
|
369
437
|
this.deliverExplicitMessage(message, targetId, delivery),
|
|
370
438
|
);
|
|
371
|
-
|
|
439
|
+
const result: AgentMessageResult = { agent_id: targetId, message_id: messageId, disposition };
|
|
440
|
+
if (turnId) result.turn_id = turnId;
|
|
441
|
+
return result;
|
|
372
442
|
} catch (error) {
|
|
373
443
|
const deliveryError = error instanceof Error ? error.message : String(error);
|
|
374
444
|
if (this.isCoordinationDeliveryCurrent(delivery)) {
|
|
@@ -423,7 +493,9 @@ export class MinimalSubagentsCoordinator {
|
|
|
423
493
|
if (pendingMessage) return Promise.resolve(pendingMessage);
|
|
424
494
|
if (agent.active_turn_id !== turnId) {
|
|
425
495
|
return Promise.reject(
|
|
426
|
-
new Error(
|
|
496
|
+
new Error(
|
|
497
|
+
`Minimal subagents wait: turn ${turnId} is unknown or no longer retained for ${agentId}; omit turn_id to wait for its oldest observable turn you have neither claimed nor received`,
|
|
498
|
+
),
|
|
427
499
|
);
|
|
428
500
|
}
|
|
429
501
|
|
|
@@ -460,11 +532,11 @@ export class MinimalSubagentsCoordinator {
|
|
|
460
532
|
});
|
|
461
533
|
}
|
|
462
534
|
|
|
463
|
-
/** Return direct-child status
|
|
535
|
+
/** Return direct-child status for a child caller; the root may inspect any descendant. */
|
|
464
536
|
status(callerId: string, agentId?: string): StatusResult {
|
|
465
537
|
this.assertCallerExists(callerId);
|
|
466
538
|
if (agentId !== undefined) {
|
|
467
|
-
this.assertCallerTargetsDirectChild(callerId, agentId, "status");
|
|
539
|
+
if (callerId !== "root") this.assertCallerTargetsDirectChild(callerId, agentId, "status");
|
|
468
540
|
return { agent: this.buildAgentDetail(this.requireAgent(agentId), false) };
|
|
469
541
|
}
|
|
470
542
|
return {
|
|
@@ -482,6 +554,56 @@ export class MinimalSubagentsCoordinator {
|
|
|
482
554
|
};
|
|
483
555
|
}
|
|
484
556
|
|
|
557
|
+
/**
|
|
558
|
+
* Find a direct child's running turn for its parent's live wait view. Only the direct parent
|
|
559
|
+
* observes it, and a requested `turnId` must be the active turn.
|
|
560
|
+
*/
|
|
561
|
+
private observeActiveTurn(
|
|
562
|
+
callerId: string,
|
|
563
|
+
agentId: string,
|
|
564
|
+
turnId: string | undefined,
|
|
565
|
+
): ObservedActiveTurn | undefined {
|
|
566
|
+
const agent = this.agents.get(agentId);
|
|
567
|
+
if (!agent || agent.parent_id !== callerId) return undefined;
|
|
568
|
+
const activeTurnId = agent.active_turn_id;
|
|
569
|
+
const startedAt = agent.active_turn_started_at;
|
|
570
|
+
if (!activeTurnId || !startedAt || (turnId !== undefined && turnId !== activeTurnId)) {
|
|
571
|
+
return undefined;
|
|
572
|
+
}
|
|
573
|
+
const runtime = this.runtimes.get(agentId);
|
|
574
|
+
if (!runtime) return undefined;
|
|
575
|
+
return { turnId: activeTurnId, startedAtMs: Date.parse(startedAt), runtime };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Count a direct child's running-turn tool calls for its waiting parent. */
|
|
579
|
+
previewActiveTurn(
|
|
580
|
+
callerId: string,
|
|
581
|
+
agentId: string,
|
|
582
|
+
turnId?: string,
|
|
583
|
+
): ActiveTurnProgress | undefined {
|
|
584
|
+
const turn = this.observeActiveTurn(callerId, agentId, turnId);
|
|
585
|
+
if (!turn) return undefined;
|
|
586
|
+
return {
|
|
587
|
+
turn_id: turn.turnId,
|
|
588
|
+
...buildActiveTurnProgress(turn.runtime.snapshotActivityMessages(), turn.startedAtMs),
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Return a direct child's Active Turn Tail, with its tool definitions, for its waiting parent. */
|
|
593
|
+
inspectActiveTurnTranscript(
|
|
594
|
+
callerId: string,
|
|
595
|
+
agentId: string,
|
|
596
|
+
turnId?: string,
|
|
597
|
+
): ChildAgentTranscriptSnapshot | undefined {
|
|
598
|
+
const turn = this.observeActiveTurn(callerId, agentId, turnId);
|
|
599
|
+
if (!turn) return undefined;
|
|
600
|
+
return selectActiveTurnTranscript(
|
|
601
|
+
this.inspectTranscript(agentId),
|
|
602
|
+
turn.startedAtMs,
|
|
603
|
+
ACTIVE_TURN_TRANSCRIPT_ASSISTANT_MESSAGES,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
485
607
|
/** Lazily inspect one Child Session Transcript without restoring a missing runtime. */
|
|
486
608
|
inspectTranscript(agentId: string): ChildAgentTranscriptSnapshot {
|
|
487
609
|
const agent = this.requireAgent(agentId);
|
|
@@ -528,15 +650,18 @@ export class MinimalSubagentsCoordinator {
|
|
|
528
650
|
this.assertCallerCanManageAgent(callerId, agentId, "cancel");
|
|
529
651
|
const target = this.requireUsableAgent(agentId, "cancel");
|
|
530
652
|
const affected = recursive ? [target, ...this.descendantsOf(agentId)] : [target];
|
|
653
|
+
const affectedAgentIds: string[] = [];
|
|
531
654
|
const cancelledTurnIds: string[] = [];
|
|
532
655
|
for (const agent of affected) {
|
|
533
656
|
const cancelledTurnId = await this.cancelActiveTurn(agent);
|
|
534
|
-
if (cancelledTurnId)
|
|
657
|
+
if (!cancelledTurnId) continue;
|
|
658
|
+
affectedAgentIds.push(agent.agent_id);
|
|
659
|
+
cancelledTurnIds.push(cancelledTurnId);
|
|
535
660
|
}
|
|
536
661
|
return {
|
|
537
662
|
agent_id: agentId,
|
|
538
663
|
recursive,
|
|
539
|
-
affected_agent_ids:
|
|
664
|
+
affected_agent_ids: affectedAgentIds,
|
|
540
665
|
cancelled_turn_ids: cancelledTurnIds,
|
|
541
666
|
};
|
|
542
667
|
}
|
|
@@ -633,6 +758,8 @@ export class MinimalSubagentsCoordinator {
|
|
|
633
758
|
this.waiters.clear();
|
|
634
759
|
this.waitHandedDeliveryIds.clear();
|
|
635
760
|
this.automaticDeliveryKeys.clear();
|
|
761
|
+
this.handedTerminalKeys.clear();
|
|
762
|
+
this.discardedRootResultKeys.clear();
|
|
636
763
|
this.automaticCoordinationDeliveryIds.clear();
|
|
637
764
|
this.deliveryLedger = createDeliveryLedger({
|
|
638
765
|
deliveries: snapshot.deliveries,
|
|
@@ -777,6 +904,102 @@ export class MinimalSubagentsCoordinator {
|
|
|
777
904
|
await Promise.allSettled(scheduled);
|
|
778
905
|
}
|
|
779
906
|
|
|
907
|
+
/** Note that a root message ended; results handed before it reached Pi's queue before the next turn boundary. */
|
|
908
|
+
recordRootMessageEnd(): void {
|
|
909
|
+
this.rootMessageEndOrder = this.handOffOrder;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Release results Pi discarded from the root's steer queue, as Esc does.
|
|
914
|
+
*
|
|
915
|
+
* Call only at a root turn boundary whose pending-message snapshot is empty. Pi persists a
|
|
916
|
+
* consumed steer before that boundary, so a queued result handed before the turn's last message
|
|
917
|
+
* either has Delivery Evidence and settles, or was discarded. A discarded result is selectable
|
|
918
|
+
* by a default wait again at once, with its batched Coordination Messages; automatic fallback
|
|
919
|
+
* re-sends it when the root's next run starts (`deliverDiscardedRootResults`).
|
|
920
|
+
*/
|
|
921
|
+
requeueDiscardedRootResults(): void {
|
|
922
|
+
if (!this.acceptingOperations) return;
|
|
923
|
+
const candidates = this.deliveryLedger.terminalDeliveries
|
|
924
|
+
.filter((delivery) => {
|
|
925
|
+
if (delivery.destination_agent_id !== "root") return false;
|
|
926
|
+
const handOff = this.handedTerminalKeys.get(
|
|
927
|
+
deliveryTurnKey(delivery.source_agent_id, delivery.source_turn_id),
|
|
928
|
+
);
|
|
929
|
+
return handOff?.queued === true && handOff.order <= this.rootMessageEndOrder;
|
|
930
|
+
})
|
|
931
|
+
.sort(
|
|
932
|
+
(left, right) =>
|
|
933
|
+
(left.sequence ?? Number.MAX_SAFE_INTEGER) - (right.sequence ?? Number.MAX_SAFE_INTEGER),
|
|
934
|
+
);
|
|
935
|
+
|
|
936
|
+
for (const delivery of candidates) {
|
|
937
|
+
const handedKey = deliveryTurnKey(delivery.source_agent_id, delivery.source_turn_id);
|
|
938
|
+
const handOff = this.handedTerminalKeys.get(handedKey);
|
|
939
|
+
if (!handOff) continue;
|
|
940
|
+
const batched = handOff.coordinationDeliveryIds.flatMap((deliveryId) => {
|
|
941
|
+
const item = findCoordinationDelivery(this.deliveryLedger, deliveryId);
|
|
942
|
+
return item ? [item] : [];
|
|
943
|
+
});
|
|
944
|
+
if (this.hasDeliveryEvidence(delivery)) {
|
|
945
|
+
for (const item of batched) {
|
|
946
|
+
if (this.hasCoordinationDeliveryEvidence(item)) this.settleCoordinationDelivery(item);
|
|
947
|
+
}
|
|
948
|
+
this.settleDelivery(delivery);
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
this.handedTerminalKeys.delete(handedKey);
|
|
953
|
+
this.discardedRootResultKeys.add(handedKey);
|
|
954
|
+
for (const item of batched) {
|
|
955
|
+
this.waitHandedDeliveryIds.delete(item.delivery_id);
|
|
956
|
+
this.queuePendingParentMessage("root", item.message, item);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** Whether a root result waits in Pi's clearable steer queue, so turn boundaries must be watched. */
|
|
962
|
+
hasQueuedRootHandOffs(): boolean {
|
|
963
|
+
for (const handOff of this.handedTerminalKeys.values()) if (handOff.queued) return true;
|
|
964
|
+
return false;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Queue results Pi discarded into a root run that just started, in Delivery Ledger sequence.
|
|
969
|
+
*
|
|
970
|
+
* Recovery never starts a root turn by itself, so Esc stays respected: the results wait, still
|
|
971
|
+
* selectable by a default wait, until the user or another delivery starts the root's next run.
|
|
972
|
+
*/
|
|
973
|
+
deliverDiscardedRootResults(): void {
|
|
974
|
+
if (
|
|
975
|
+
this.discardedRootResultKeys.size === 0 ||
|
|
976
|
+
!this.acceptingOperations ||
|
|
977
|
+
this.dependencies.root.isIdle()
|
|
978
|
+
)
|
|
979
|
+
return;
|
|
980
|
+
const discarded = this.deliveryLedger.terminalDeliveries
|
|
981
|
+
.filter((delivery) =>
|
|
982
|
+
this.discardedRootResultKeys.has(
|
|
983
|
+
deliveryTurnKey(delivery.source_agent_id, delivery.source_turn_id),
|
|
984
|
+
),
|
|
985
|
+
)
|
|
986
|
+
.sort(
|
|
987
|
+
(left, right) =>
|
|
988
|
+
(left.sequence ?? Number.MAX_SAFE_INTEGER) - (right.sequence ?? Number.MAX_SAFE_INTEGER),
|
|
989
|
+
);
|
|
990
|
+
this.discardedRootResultKeys.clear();
|
|
991
|
+
for (const delivery of discarded) {
|
|
992
|
+
const result = delivery.result ?? this.agents.get(delivery.source_agent_id)?.latest_result;
|
|
993
|
+
if (
|
|
994
|
+
delivery.path !== "message" ||
|
|
995
|
+
result?.status !== "completed" ||
|
|
996
|
+
result.turn_id !== delivery.source_turn_id
|
|
997
|
+
)
|
|
998
|
+
continue;
|
|
999
|
+
this.trackBackgroundOperation(this.deliverAutomaticResult(result, delivery, true));
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
780
1003
|
/** Clone complete child leaves for root fork ownership without ever sharing source session paths. */
|
|
781
1004
|
async prepareFork(sourceRootSessionFile: string): Promise<ForkSnapshot> {
|
|
782
1005
|
const activeRootChildren = this.childrenOf("root");
|
|
@@ -1070,17 +1293,27 @@ export class MinimalSubagentsCoordinator {
|
|
|
1070
1293
|
if (result.status !== "completed") this.removeSettledEmptyTurnClaim(agent.agent_id, turnId);
|
|
1071
1294
|
}
|
|
1072
1295
|
|
|
1296
|
+
/**
|
|
1297
|
+
* Hand one terminal result to its parent through automatic fallback.
|
|
1298
|
+
*
|
|
1299
|
+
* By default it waits its turn in the recipient queue, then the wait-claim grace period. A
|
|
1300
|
+
* `resume` hand-off re-sends a root result Pi discarded: its grace period has passed and its
|
|
1301
|
+
* sequence precedes everything still queued, so it is handed at once, synchronously up to Pi.
|
|
1302
|
+
*/
|
|
1073
1303
|
private async deliverAutomaticResult(
|
|
1074
1304
|
result: TurnResult,
|
|
1075
1305
|
delivery: PersistedDelivery,
|
|
1306
|
+
resume = false,
|
|
1076
1307
|
): Promise<void> {
|
|
1077
1308
|
const deliveryKey = agentDeliveryKey(delivery.source_agent_id, delivery.source_turn_id);
|
|
1309
|
+
const handedKey = deliveryTurnKey(delivery.source_agent_id, delivery.source_turn_id);
|
|
1078
1310
|
if (this.automaticDeliveryKeys.has(deliveryKey)) return;
|
|
1079
|
-
this.
|
|
1080
|
-
|
|
1311
|
+
const epoch = this.lifecycleEpoch;
|
|
1312
|
+
this.automaticDeliveryKeys.set(deliveryKey, epoch);
|
|
1313
|
+
const graceMs = resume ? 0 : this.deliveryGraceMs();
|
|
1081
1314
|
let batchedCoordinationDeliveries: PersistedCoordinationDelivery[] = [];
|
|
1082
1315
|
try {
|
|
1083
|
-
|
|
1316
|
+
const handOff = async () => {
|
|
1084
1317
|
if (delivery.destination_agent_id !== "root") {
|
|
1085
1318
|
await this.ensureRuntime(
|
|
1086
1319
|
this.requireUsableAgent(delivery.destination_agent_id, "message"),
|
|
@@ -1088,6 +1321,8 @@ export class MinimalSubagentsCoordinator {
|
|
|
1088
1321
|
if (!this.isTerminalDeliveryCurrent(delivery)) return;
|
|
1089
1322
|
}
|
|
1090
1323
|
if (graceMs > 0) await new Promise((resolve) => setTimeout(resolve, graceMs));
|
|
1324
|
+
// A branch change during the grace period abandons this hand-off; restore replays it.
|
|
1325
|
+
if (epoch !== this.lifecycleEpoch) return;
|
|
1091
1326
|
if (!this.acceptingOperations || this.shouldStopAutomaticTerminalDelivery(delivery)) return;
|
|
1092
1327
|
if (this.hasDeliveryEvidence(delivery)) {
|
|
1093
1328
|
this.settleDelivery(delivery);
|
|
@@ -1113,6 +1348,13 @@ export class MinimalSubagentsCoordinator {
|
|
|
1113
1348
|
for (const batchedDelivery of batchedCoordinationDeliveries) {
|
|
1114
1349
|
this.waitHandedDeliveryIds.add(batchedDelivery.delivery_id);
|
|
1115
1350
|
}
|
|
1351
|
+
// Installed before delivery so a settlement inside it removes the marker. Delivery up to
|
|
1352
|
+
// Pi's `sendMessage` is synchronous, so the idle check predicts a steer versus a new turn.
|
|
1353
|
+
this.handedTerminalKeys.set(handedKey, {
|
|
1354
|
+
order: ++this.handOffOrder,
|
|
1355
|
+
queued: delivery.destination_agent_id === "root" && !this.dependencies.root.isIdle(),
|
|
1356
|
+
coordinationDeliveryIds: batchedCoordinationDeliveries.map((item) => item.delivery_id),
|
|
1357
|
+
});
|
|
1116
1358
|
await this.deliverToRecipient(
|
|
1117
1359
|
delivery.destination_agent_id,
|
|
1118
1360
|
combineCoordinatorMessages([
|
|
@@ -1123,8 +1365,14 @@ export class MinimalSubagentsCoordinator {
|
|
|
1123
1365
|
this.isTerminalDeliveryCurrent(delivery) &&
|
|
1124
1366
|
batchedCoordinationDeliveries.every((item) => this.isCoordinationDeliveryCurrent(item)),
|
|
1125
1367
|
);
|
|
1126
|
-
}
|
|
1368
|
+
};
|
|
1369
|
+
await (resume
|
|
1370
|
+
? handOff()
|
|
1371
|
+
: this.enqueueRecipientDelivery(delivery.destination_agent_id, handOff));
|
|
1127
1372
|
} catch (error) {
|
|
1373
|
+
// A hand-off abandoned by a branch change must not touch the restored branch's state.
|
|
1374
|
+
if (epoch !== this.lifecycleEpoch) return;
|
|
1375
|
+
this.handedTerminalKeys.delete(handedKey);
|
|
1128
1376
|
for (const batchedDelivery of batchedCoordinationDeliveries) {
|
|
1129
1377
|
this.waitHandedDeliveryIds.delete(batchedDelivery.delivery_id);
|
|
1130
1378
|
}
|
|
@@ -1144,7 +1392,10 @@ export class MinimalSubagentsCoordinator {
|
|
|
1144
1392
|
}),
|
|
1145
1393
|
);
|
|
1146
1394
|
} finally {
|
|
1147
|
-
|
|
1395
|
+
// Restore's replay may own the key again; release it only from the call that holds it.
|
|
1396
|
+
if (this.automaticDeliveryKeys.get(deliveryKey) === epoch) {
|
|
1397
|
+
this.automaticDeliveryKeys.delete(deliveryKey);
|
|
1398
|
+
}
|
|
1148
1399
|
}
|
|
1149
1400
|
}
|
|
1150
1401
|
|
|
@@ -1230,6 +1481,7 @@ export class MinimalSubagentsCoordinator {
|
|
|
1230
1481
|
sourceAgentId,
|
|
1231
1482
|
destinationAgentId,
|
|
1232
1483
|
waitHandedDeliveryIds: this.waitHandedDeliveryIds,
|
|
1484
|
+
handedTurnKeys: this.handedTerminalKeys,
|
|
1233
1485
|
activeTurnId: agent?.active_turn_id,
|
|
1234
1486
|
latestResultTurnId: agent?.latest_result?.turn_id,
|
|
1235
1487
|
});
|
|
@@ -1315,24 +1567,27 @@ export class MinimalSubagentsCoordinator {
|
|
|
1315
1567
|
message: CoordinatorMessage,
|
|
1316
1568
|
targetId: string,
|
|
1317
1569
|
delivery: PersistedCoordinationDelivery,
|
|
1318
|
-
): Promise<AgentMessageDisposition> {
|
|
1570
|
+
): Promise<{ disposition: AgentMessageDisposition; turnId?: string }> {
|
|
1319
1571
|
this.waitHandedDeliveryIds.add(delivery.delivery_id);
|
|
1320
1572
|
try {
|
|
1321
|
-
await this.deliverToRecipient(targetId, message, () =>
|
|
1573
|
+
const startedTurnId = await this.deliverToRecipient(targetId, message, () =>
|
|
1322
1574
|
this.isCoordinationDeliveryCurrent(delivery),
|
|
1323
1575
|
);
|
|
1324
|
-
return
|
|
1576
|
+
return startedTurnId
|
|
1577
|
+
? { disposition: "started-turn", turnId: startedTurnId }
|
|
1578
|
+
: { disposition: "queued" };
|
|
1325
1579
|
} catch (error) {
|
|
1326
1580
|
this.waitHandedDeliveryIds.delete(delivery.delivery_id);
|
|
1327
1581
|
throw error;
|
|
1328
1582
|
}
|
|
1329
1583
|
}
|
|
1330
1584
|
|
|
1585
|
+
/** Deliver one message, returning the turn ID when it starts a new turn on an idle child. */
|
|
1331
1586
|
private async deliverToRecipient(
|
|
1332
1587
|
targetId: string,
|
|
1333
1588
|
message: CoordinatorMessage,
|
|
1334
1589
|
isCurrentDelivery: () => boolean = () => true,
|
|
1335
|
-
): Promise<
|
|
1590
|
+
): Promise<string | undefined> {
|
|
1336
1591
|
if (!isCurrentDelivery()) {
|
|
1337
1592
|
throw new Error("Minimal subagents delivery abandoned after session branch change");
|
|
1338
1593
|
}
|
|
@@ -1352,7 +1607,7 @@ export class MinimalSubagentsCoordinator {
|
|
|
1352
1607
|
const visibleMessage = addCoordinatorMessageEnvelope(message);
|
|
1353
1608
|
if (target.active_turn_id || runtime.isRunning) {
|
|
1354
1609
|
await runtime.queueCoordinatorMessage(visibleMessage);
|
|
1355
|
-
return;
|
|
1610
|
+
return undefined;
|
|
1356
1611
|
}
|
|
1357
1612
|
const turnId = this.beginTurn(target);
|
|
1358
1613
|
const runMessage = runtime
|
|
@@ -1374,6 +1629,7 @@ export class MinimalSubagentsCoordinator {
|
|
|
1374
1629
|
}
|
|
1375
1630
|
});
|
|
1376
1631
|
this.trackBackgroundOperation(runMessage);
|
|
1632
|
+
return turnId;
|
|
1377
1633
|
}
|
|
1378
1634
|
|
|
1379
1635
|
private trackBackgroundOperation(operation: Promise<void>): void {
|
|
@@ -1441,6 +1697,19 @@ export class MinimalSubagentsCoordinator {
|
|
|
1441
1697
|
}),
|
|
1442
1698
|
);
|
|
1443
1699
|
}
|
|
1700
|
+
this.dropStaleHandedTerminalKeys();
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
/** Forget handed markers whose terminal delivery left the ledger by settlement, pruning, or deletion. */
|
|
1704
|
+
private dropStaleHandedTerminalKeys(): void {
|
|
1705
|
+
const pending = new Set(
|
|
1706
|
+
this.deliveryLedger.terminalDeliveries.map((delivery) =>
|
|
1707
|
+
deliveryTurnKey(delivery.source_agent_id, delivery.source_turn_id),
|
|
1708
|
+
),
|
|
1709
|
+
);
|
|
1710
|
+
for (const key of this.handedTerminalKeys.keys()) {
|
|
1711
|
+
if (!pending.has(key)) this.handedTerminalKeys.delete(key);
|
|
1712
|
+
}
|
|
1444
1713
|
}
|
|
1445
1714
|
|
|
1446
1715
|
private isTerminalDeliveryCurrent(delivery: PersistedDelivery): boolean {
|
|
@@ -1516,6 +1785,7 @@ export class MinimalSubagentsCoordinator {
|
|
|
1516
1785
|
delivery.source_agent_id,
|
|
1517
1786
|
delivery.source_turn_id,
|
|
1518
1787
|
).ledger;
|
|
1788
|
+
this.dropStaleHandedTerminalKeys();
|
|
1519
1789
|
this.dependencies.registry.append(
|
|
1520
1790
|
createRegistryEvent(this.dependencies.registry.rootSessionId, "delivery-settled", {
|
|
1521
1791
|
source_agent_id: delivery.source_agent_id,
|
|
@@ -1527,9 +1797,6 @@ export class MinimalSubagentsCoordinator {
|
|
|
1527
1797
|
|
|
1528
1798
|
private buildAgentSummary(agent: PersistedAgent, includeDescendants = true): AgentSummary {
|
|
1529
1799
|
const directChildren = this.childrenOf(agent.agent_id);
|
|
1530
|
-
const children = includeDescendants
|
|
1531
|
-
? directChildren.map((child) => this.buildAgentSummary(child))
|
|
1532
|
-
: [];
|
|
1533
1800
|
const elapsed = agent.active_turn_started_at
|
|
1534
1801
|
? Math.max(0, this.now().getTime() - new Date(agent.active_turn_started_at).getTime())
|
|
1535
1802
|
: undefined;
|
|
@@ -1538,7 +1805,7 @@ export class MinimalSubagentsCoordinator {
|
|
|
1538
1805
|
model: agent.launch_contract.model,
|
|
1539
1806
|
thinking_level: agent.launch_contract.thinking_level,
|
|
1540
1807
|
};
|
|
1541
|
-
|
|
1808
|
+
const summary: AgentSummary = {
|
|
1542
1809
|
agent_id: agent.agent_id,
|
|
1543
1810
|
parent_id: agent.parent_id,
|
|
1544
1811
|
state: agent.active_turn_id ? "running" : "idle",
|
|
@@ -1553,8 +1820,11 @@ export class MinimalSubagentsCoordinator {
|
|
|
1553
1820
|
latest_activity_at: agent.latest_activity_at ?? agent.created_at,
|
|
1554
1821
|
task: agent.task,
|
|
1555
1822
|
child_count: directChildren.length,
|
|
1556
|
-
children,
|
|
1557
1823
|
};
|
|
1824
|
+
if (includeDescendants) {
|
|
1825
|
+
summary.children = directChildren.map((child) => this.buildAgentSummary(child));
|
|
1826
|
+
}
|
|
1827
|
+
return summary;
|
|
1558
1828
|
}
|
|
1559
1829
|
|
|
1560
1830
|
private buildAgentDetail(agent: PersistedAgent, includeDescendants = true): AgentDetail {
|
|
@@ -1581,6 +1851,7 @@ export class MinimalSubagentsCoordinator {
|
|
|
1581
1851
|
private pruneDeliveryStateForDeletedAgent(agentId: string): void {
|
|
1582
1852
|
const previousCoordinationDeliveries = this.deliveryLedger.coordinationDeliveries;
|
|
1583
1853
|
this.deliveryLedger = pruneDeliveryLedgerAgents(this.deliveryLedger, [agentId]).ledger;
|
|
1854
|
+
this.dropStaleHandedTerminalKeys();
|
|
1584
1855
|
for (const delivery of previousCoordinationDeliveries) {
|
|
1585
1856
|
if (!this.isCoordinationDeliveryCurrent(delivery)) {
|
|
1586
1857
|
this.waitHandedDeliveryIds.delete(delivery.delivery_id);
|
|
@@ -45,16 +45,19 @@ export interface AddedCoordinationDelivery extends DeliveryLedgerTransition {
|
|
|
45
45
|
delivery: PersistedCoordinationDelivery;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/** Describes caller-visible inputs used to select the oldest observable source turn. */
|
|
48
|
+
/** Describes caller-visible inputs used to select the oldest observable source turn the destination has neither claimed nor received. */
|
|
49
49
|
export interface SelectObservableDeliveryTurnOptions {
|
|
50
50
|
sourceAgentId: string;
|
|
51
51
|
destinationAgentId: string;
|
|
52
52
|
waitHandedDeliveryIds: ReadonlySet<string>;
|
|
53
|
+
/** `deliveryTurnKey`s of terminal results already queued to the destination automatically. */
|
|
54
|
+
handedTurnKeys?: { has(key: string): boolean };
|
|
53
55
|
activeTurnId?: string;
|
|
54
56
|
latestResultTurnId?: string;
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
|
|
59
|
+
/** Compound source-agent and source-turn key shared by claims and process-local handed markers. */
|
|
60
|
+
export function deliveryTurnKey(sourceAgentId: string, sourceTurnId: string): string {
|
|
58
61
|
return `${sourceAgentId}\u0000${sourceTurnId}`;
|
|
59
62
|
}
|
|
60
63
|
|
|
@@ -420,19 +423,20 @@ export function releaseEmptyDeliveryLedgerTurn(
|
|
|
420
423
|
: releaseDeliveryLedgerTurn(ledger, sourceAgentId, sourceTurnId);
|
|
421
424
|
}
|
|
422
425
|
|
|
423
|
-
/**
|
|
426
|
+
/**
|
|
427
|
+
* Select the oldest sequenced observable source turn whose terminal result the caller has neither
|
|
428
|
+
* claimed nor been handed automatically, falling back to the active, then latest, turn.
|
|
429
|
+
*/
|
|
424
430
|
export function selectObservableDeliveryTurn(
|
|
425
431
|
ledger: DeliveryLedger,
|
|
426
432
|
options: SelectObservableDeliveryTurnOptions,
|
|
427
433
|
): string | undefined {
|
|
428
|
-
const candidates = new Map<string,
|
|
434
|
+
const candidates = new Map<string, number>();
|
|
429
435
|
const addCandidate = (turnId: string, sequence: number) => {
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
sequence,
|
|
433
|
-
};
|
|
436
|
+
const turnKey = deliveryTurnKey(options.sourceAgentId, turnId);
|
|
437
|
+
if (ledger.waitClaimedTurns.includes(turnKey) || options.handedTurnKeys?.has(turnKey)) return;
|
|
434
438
|
const existing = candidates.get(turnId);
|
|
435
|
-
if (
|
|
439
|
+
if (existing === undefined || sequence < existing) candidates.set(turnId, sequence);
|
|
436
440
|
};
|
|
437
441
|
for (const delivery of ledger.coordinationDeliveries) {
|
|
438
442
|
if (
|
|
@@ -451,10 +455,7 @@ export function selectObservableDeliveryTurn(
|
|
|
451
455
|
addCandidate(delivery.source_turn_id, delivery.sequence ?? Number.MAX_SAFE_INTEGER);
|
|
452
456
|
}
|
|
453
457
|
}
|
|
454
|
-
const retained = [...candidates.entries()].sort((left, right) =>
|
|
455
|
-
if (left[1].claimed !== right[1].claimed) return left[1].claimed ? -1 : 1;
|
|
456
|
-
return left[1].sequence - right[1].sequence;
|
|
457
|
-
})[0]?.[0];
|
|
458
|
+
const retained = [...candidates.entries()].sort((left, right) => left[1] - right[1])[0]?.[0];
|
|
458
459
|
return retained ?? options.activeTurnId ?? options.latestResultTurnId;
|
|
459
460
|
}
|
|
460
461
|
|