@ian-pascoe/pi-minimal-subagents 0.1.1 → 0.2.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 +58 -5
- package/package.json +1 -1
- package/src/minimal-subagents-capabilities.ts +7 -0
- package/src/minimal-subagents-config.ts +182 -65
- package/src/minimal-subagents-context.ts +7 -1
- package/src/minimal-subagents-coordinator.ts +938 -114
- package/src/minimal-subagents-delivery-ledger.ts +529 -0
- package/src/minimal-subagents-extension.ts +382 -64
- package/src/minimal-subagents-fork-lifecycle.ts +22 -12
- package/src/minimal-subagents-message-envelope.ts +20 -0
- package/src/minimal-subagents-registry-wire.ts +269 -0
- package/src/minimal-subagents-registry.ts +1505 -132
- package/src/minimal-subagents-render-contract.ts +301 -0
- package/src/minimal-subagents-rendering.ts +513 -372
- package/src/minimal-subagents-session-wire.ts +42 -0
- package/src/minimal-subagents-sessions.ts +427 -101
- package/src/minimal-subagents-shutdown.ts +1 -1
- package/src/minimal-subagents-tool-schemas.ts +21 -7
- package/src/minimal-subagents-tools.ts +70 -21
- package/src/minimal-subagents-types.ts +65 -14
- package/src/minimal-subagents-ui.ts +8 -5
|
@@ -16,8 +16,12 @@ import {
|
|
|
16
16
|
SessionManager,
|
|
17
17
|
SettingsManager,
|
|
18
18
|
sessionEntryToContextMessages,
|
|
19
|
+
type AgentSessionEvent,
|
|
20
|
+
type SessionEntry,
|
|
19
21
|
type ToolDefinition,
|
|
20
22
|
} from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import type { Static, TSchema } from "typebox";
|
|
24
|
+
import { Value } from "typebox/value";
|
|
21
25
|
import {
|
|
22
26
|
buildSubagentSystemPrompt,
|
|
23
27
|
snapshotCommittedContext,
|
|
@@ -27,7 +31,20 @@ import {
|
|
|
27
31
|
DEFAULT_MAX_SUBAGENT_DEPTH,
|
|
28
32
|
getSubagentDepth,
|
|
29
33
|
} from "./minimal-subagents-capabilities.js";
|
|
30
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
CHILD_IDENTITY_ENTRY_TYPE,
|
|
36
|
+
FORK_CLONE_ENTRY_TYPE,
|
|
37
|
+
FORK_OWNERSHIP_ENTRY_TYPE,
|
|
38
|
+
} from "./minimal-subagents-registry.js";
|
|
39
|
+
import {
|
|
40
|
+
ChildSessionIdentityRecordSchema,
|
|
41
|
+
DeliveryEvidenceDetailsSchema,
|
|
42
|
+
ForkCloneProvenanceRecordSchema,
|
|
43
|
+
ForkOwnershipRecordSchema,
|
|
44
|
+
type ChildSessionIdentityRecord,
|
|
45
|
+
type ForkCloneProvenanceRecord,
|
|
46
|
+
type ForkOwnershipRecord,
|
|
47
|
+
} from "./minimal-subagents-session-wire.js";
|
|
31
48
|
import { addMinimalSubagentsUsage } from "./minimal-subagents-usage.js";
|
|
32
49
|
import type {
|
|
33
50
|
AgentSessionFactory,
|
|
@@ -69,6 +86,19 @@ interface ChildResourceLoaderOptionsInput {
|
|
|
69
86
|
settingsManager?: SettingsManager;
|
|
70
87
|
}
|
|
71
88
|
|
|
89
|
+
/** Moves one verified child session file to trash and reports command unavailability. */
|
|
90
|
+
export interface SessionFileTrashCapability {
|
|
91
|
+
moveSessionFile(sessionFile: string): Promise<Error | undefined>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const execFileSessionTrashCapability: SessionFileTrashCapability = {
|
|
95
|
+
moveSessionFile: (sessionFile) =>
|
|
96
|
+
new Promise((resolvePromise) => {
|
|
97
|
+
const trashArguments = sessionFile.startsWith("-") ? ["--", sessionFile] : [sessionFile];
|
|
98
|
+
execFile("trash", trashArguments, (cause) => resolvePromise(cause ?? undefined));
|
|
99
|
+
}),
|
|
100
|
+
};
|
|
101
|
+
|
|
72
102
|
/** Configures root ownership, model scope, child resources, and coordinator tool injection for Pi sessions. */
|
|
73
103
|
export interface PiAgentSessionFactoryOptions {
|
|
74
104
|
cwd: string;
|
|
@@ -82,6 +112,7 @@ export interface PiAgentSessionFactoryOptions {
|
|
|
82
112
|
availableToolNames: readonly string[];
|
|
83
113
|
projectTrusted: boolean;
|
|
84
114
|
maxSubagentDepth?: number;
|
|
115
|
+
sessionFileTrash?: SessionFileTrashCapability;
|
|
85
116
|
getCoordinatorTools: (callerId: string) => ToolDefinition[];
|
|
86
117
|
onChildSessionActivity?: () => void;
|
|
87
118
|
}
|
|
@@ -123,6 +154,7 @@ function appendImportedMessage(sessionManager: SessionManager, message: AgentMes
|
|
|
123
154
|
);
|
|
124
155
|
return;
|
|
125
156
|
}
|
|
157
|
+
// SAFETY: AgentMessage is Pi's broader message union; non-session variants were handled above.
|
|
126
158
|
sessionManager.appendMessage(message as Parameters<SessionManager["appendMessage"]>[0]);
|
|
127
159
|
}
|
|
128
160
|
|
|
@@ -159,7 +191,147 @@ export function createPersistentChildIdentity(
|
|
|
159
191
|
writeFileSync(sessionFile, `${lines}\n`, "utf8");
|
|
160
192
|
sessionManager = SessionManager.open(sessionFile, options.sessionDir, options.cwd);
|
|
161
193
|
}
|
|
162
|
-
return {
|
|
194
|
+
return {
|
|
195
|
+
sessionFile,
|
|
196
|
+
sessionId: sessionManager.getSessionId(),
|
|
197
|
+
sessionLeafId: sessionManager.getLeafId() ?? undefined,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function findLatestChildSessionRecord<TRecordSchema extends TSchema>(
|
|
202
|
+
entries: ReturnType<SessionManager["getBranch"]>,
|
|
203
|
+
customType: string,
|
|
204
|
+
schema: TRecordSchema,
|
|
205
|
+
): Static<TRecordSchema> | undefined {
|
|
206
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
207
|
+
const entry = entries[index];
|
|
208
|
+
if (!entry || entry.type !== "custom" || entry.customType !== customType) continue;
|
|
209
|
+
if (Value.Check(schema, entry.data)) return entry.data;
|
|
210
|
+
}
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function findLatestForkGeneration(
|
|
215
|
+
entries: ReturnType<SessionManager["getBranch"]>,
|
|
216
|
+
): { identity: ChildSessionIdentityRecord; provenance: ForkCloneProvenanceRecord } | undefined {
|
|
217
|
+
for (let provenanceIndex = entries.length - 1; provenanceIndex >= 0; provenanceIndex--) {
|
|
218
|
+
const provenanceEntry = entries[provenanceIndex];
|
|
219
|
+
if (
|
|
220
|
+
!provenanceEntry ||
|
|
221
|
+
provenanceEntry.type !== "custom" ||
|
|
222
|
+
provenanceEntry.customType !== FORK_CLONE_ENTRY_TYPE
|
|
223
|
+
) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (!Value.Check(ForkCloneProvenanceRecordSchema, provenanceEntry.data)) continue;
|
|
227
|
+
const provenance = provenanceEntry.data;
|
|
228
|
+
for (let identityIndex = provenanceIndex - 1; identityIndex >= 0; identityIndex--) {
|
|
229
|
+
const identityEntry = entries[identityIndex];
|
|
230
|
+
if (
|
|
231
|
+
!identityEntry ||
|
|
232
|
+
identityEntry.type !== "custom" ||
|
|
233
|
+
identityEntry.customType !== CHILD_IDENTITY_ENTRY_TYPE
|
|
234
|
+
) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (Value.Check(ChildSessionIdentityRecordSchema, identityEntry.data)) {
|
|
238
|
+
return { identity: identityEntry.data, provenance };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function findCurrentForkOwnership(
|
|
247
|
+
entries: ReturnType<SessionManager["getBranch"]>,
|
|
248
|
+
cloneSessionId: string,
|
|
249
|
+
): ForkOwnershipRecord | undefined {
|
|
250
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
251
|
+
const entry = entries[index];
|
|
252
|
+
if (!entry || entry.type !== "custom" || entry.customType !== FORK_OWNERSHIP_ENTRY_TYPE)
|
|
253
|
+
continue;
|
|
254
|
+
if (
|
|
255
|
+
Value.Check(ForkOwnershipRecordSchema, entry.data) &&
|
|
256
|
+
entry.data.clone_session_id === cloneSessionId
|
|
257
|
+
) {
|
|
258
|
+
return entry.data;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function verifyForkCloneProvenance(
|
|
265
|
+
branch: ReturnType<SessionManager["getBranch"]>,
|
|
266
|
+
agent: PersistedAgent,
|
|
267
|
+
sourceRootSessionId: string,
|
|
268
|
+
): ForkCloneProvenanceRecord {
|
|
269
|
+
const provenance = findLatestForkGeneration(branch)?.provenance;
|
|
270
|
+
if (
|
|
271
|
+
!provenance ||
|
|
272
|
+
provenance.source_root_session_id !== sourceRootSessionId ||
|
|
273
|
+
provenance.source_agent_id !== agent.agent_id
|
|
274
|
+
) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
`Minimal subagents session identity mismatch: fork provenance for ${agent.agent_id}`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
return provenance;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Verify that a persisted child session path belongs to the expected canonical agent and root. */
|
|
283
|
+
export function verifyChildSessionIdentity(
|
|
284
|
+
sessionManager: SessionManager,
|
|
285
|
+
agent: PersistedAgent,
|
|
286
|
+
rootSessionId: string,
|
|
287
|
+
): void {
|
|
288
|
+
if (sessionManager.getSessionId() !== agent.session_id) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`Minimal subagents session identity mismatch: session ID for ${agent.agent_id}`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
const identityBranch = sessionManager.getBranch(agent.session_leaf_id);
|
|
294
|
+
const generation = findLatestForkGeneration(identityBranch);
|
|
295
|
+
const identity =
|
|
296
|
+
generation?.identity ??
|
|
297
|
+
findLatestChildSessionRecord(
|
|
298
|
+
identityBranch,
|
|
299
|
+
CHILD_IDENTITY_ENTRY_TYPE,
|
|
300
|
+
ChildSessionIdentityRecordSchema,
|
|
301
|
+
);
|
|
302
|
+
if (!identity) {
|
|
303
|
+
throw new Error(`Minimal subagents session identity missing for ${agent.agent_id}`);
|
|
304
|
+
}
|
|
305
|
+
if (
|
|
306
|
+
identity.canonical_agent_id !== agent.agent_id ||
|
|
307
|
+
identity.direct_parent_id !== agent.parent_id ||
|
|
308
|
+
identity.created_at !== agent.created_at
|
|
309
|
+
) {
|
|
310
|
+
throw new Error(`Minimal subagents session identity mismatch: ownership for ${agent.agent_id}`);
|
|
311
|
+
}
|
|
312
|
+
const ownership = findCurrentForkOwnership(identityBranch, sessionManager.getSessionId());
|
|
313
|
+
if (ownership || identity.original_root_session_id !== rootSessionId) {
|
|
314
|
+
const provenance = generation?.provenance;
|
|
315
|
+
if (
|
|
316
|
+
!ownership ||
|
|
317
|
+
!provenance ||
|
|
318
|
+
ownership.destination_root_session_id !== rootSessionId ||
|
|
319
|
+
ownership.source_root_session_id !== identity.original_root_session_id ||
|
|
320
|
+
ownership.source_root_session_id !== provenance.source_root_session_id ||
|
|
321
|
+
ownership.source_agent_id !== agent.agent_id ||
|
|
322
|
+
ownership.source_agent_id !== provenance.source_agent_id ||
|
|
323
|
+
ownership.source_session_id !== provenance.source_session_id ||
|
|
324
|
+
ownership.clone_session_id !== sessionManager.getSessionId() ||
|
|
325
|
+
ownership.direct_parent_id !== agent.parent_id
|
|
326
|
+
) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`Minimal subagents session identity mismatch: root owner for ${agent.agent_id}`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (agent.session_leaf_id && !sessionManager.getEntry(agent.session_leaf_id)) {
|
|
333
|
+
throw new Error(`Minimal subagents session identity mismatch: leaf for ${agent.agent_id}`);
|
|
334
|
+
}
|
|
163
335
|
}
|
|
164
336
|
|
|
165
337
|
/** Build child resources while filtering recursive coordinator loading and honoring project-context omission. */
|
|
@@ -206,29 +378,44 @@ export function createChildResourceLoaderOptions(
|
|
|
206
378
|
|
|
207
379
|
/** Find durable keyed evidence for exactly-once wait or custom-result delivery. */
|
|
208
380
|
export function findDeliveryEvidence(
|
|
209
|
-
entries: readonly
|
|
381
|
+
entries: readonly SessionEntry[],
|
|
210
382
|
sourceAgentId: string,
|
|
211
383
|
sourceTurnId: string,
|
|
384
|
+
deliveryId?: string,
|
|
212
385
|
): boolean {
|
|
213
386
|
return entries.some((entry) => {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
387
|
+
let details: SessionEntry extends infer TEntry
|
|
388
|
+
? TEntry extends { details?: infer TDetails }
|
|
389
|
+
? TDetails
|
|
390
|
+
: undefined
|
|
391
|
+
: undefined;
|
|
392
|
+
let waitToolResult = false;
|
|
393
|
+
if (
|
|
394
|
+
entry.type === "custom_message" &&
|
|
395
|
+
(entry.customType === "minimal-subagents.result" ||
|
|
396
|
+
(deliveryId !== undefined && entry.customType === "minimal-subagents.message"))
|
|
397
|
+
) {
|
|
398
|
+
details = entry.details;
|
|
399
|
+
} else if (
|
|
400
|
+
entry.type === "message" &&
|
|
401
|
+
entry.message.role === "toolResult" &&
|
|
402
|
+
entry.message.toolName === "subagent_wait"
|
|
403
|
+
) {
|
|
404
|
+
details = entry.message.details;
|
|
405
|
+
waitToolResult = true;
|
|
406
|
+
} else {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
if (!Value.Check(DeliveryEvidenceDetailsSchema, details)) return false;
|
|
410
|
+
if (deliveryId !== undefined) {
|
|
411
|
+
return (
|
|
412
|
+
details.source_agent_id === sourceAgentId &&
|
|
413
|
+
details.source_turn_id === sourceTurnId &&
|
|
414
|
+
(details.delivery_id === deliveryId || details.message_id === deliveryId)
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
if (waitToolResult && details.event === "message") return false;
|
|
418
|
+
return details.source_agent_id === sourceAgentId && details.source_turn_id === sourceTurnId;
|
|
232
419
|
});
|
|
233
420
|
}
|
|
234
421
|
|
|
@@ -248,6 +435,77 @@ function assistantText(message: AgentMessage | undefined): string {
|
|
|
248
435
|
.join("\n");
|
|
249
436
|
}
|
|
250
437
|
|
|
438
|
+
/** Collects finalized turn messages without relying on mutable post-compaction session state. */
|
|
439
|
+
export class ChildTurnOutcomeCollector {
|
|
440
|
+
private readonly messages: AgentMessage[] = [];
|
|
441
|
+
private readonly unsubscribe: () => void;
|
|
442
|
+
|
|
443
|
+
constructor(session: Pick<AgentSession, "subscribe">) {
|
|
444
|
+
this.unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
445
|
+
if (event.type === "message_end") this.messages.push(event.message);
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
dispose(): void {
|
|
450
|
+
this.unsubscribe();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
toOutcome(aborted: boolean): RuntimeTurnOutcome {
|
|
454
|
+
const finalAssistant = [...this.messages]
|
|
455
|
+
.reverse()
|
|
456
|
+
.find((message) => message.role === "assistant");
|
|
457
|
+
if (!finalAssistant || finalAssistant.role !== "assistant") {
|
|
458
|
+
return {
|
|
459
|
+
status: aborted ? "cancelled" : "failed",
|
|
460
|
+
output: "",
|
|
461
|
+
error: "No terminal assistant response",
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
if (finalAssistant.stopReason === "aborted") {
|
|
465
|
+
return {
|
|
466
|
+
status: "cancelled",
|
|
467
|
+
output: assistantText(finalAssistant),
|
|
468
|
+
error: finalAssistant.errorMessage,
|
|
469
|
+
usage: sumUsage(this.messages),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
if (finalAssistant.stopReason === "error") {
|
|
473
|
+
return {
|
|
474
|
+
status: "failed",
|
|
475
|
+
output: assistantText(finalAssistant),
|
|
476
|
+
error: finalAssistant.errorMessage ?? "Provider request failed",
|
|
477
|
+
usage: sumUsage(this.messages),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
return {
|
|
481
|
+
status: "completed",
|
|
482
|
+
output: assistantText(finalAssistant),
|
|
483
|
+
usage: sumUsage(this.messages),
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Run one child operation while retaining its finalized outcome across compaction. */
|
|
489
|
+
export async function captureChildTurnOutcome(
|
|
490
|
+
session: Pick<AgentSession, "subscribe">,
|
|
491
|
+
operation: () => Promise<void>,
|
|
492
|
+
isAborted: () => boolean,
|
|
493
|
+
): Promise<RuntimeTurnOutcome> {
|
|
494
|
+
const collector = new ChildTurnOutcomeCollector(session);
|
|
495
|
+
try {
|
|
496
|
+
await operation();
|
|
497
|
+
return collector.toOutcome(isAborted());
|
|
498
|
+
} catch (error) {
|
|
499
|
+
return {
|
|
500
|
+
status: isAborted() ? "cancelled" : "failed",
|
|
501
|
+
output: "",
|
|
502
|
+
error: error instanceof Error ? error.message : String(error),
|
|
503
|
+
};
|
|
504
|
+
} finally {
|
|
505
|
+
collector.dispose();
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
251
509
|
class PiChildAgentRuntime implements ChildAgentRuntime {
|
|
252
510
|
private aborted = false;
|
|
253
511
|
private readonly unsubscribe: () => void;
|
|
@@ -280,6 +538,10 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
|
|
|
280
538
|
return this.session.sessionId;
|
|
281
539
|
}
|
|
282
540
|
|
|
541
|
+
get sessionLeafId(): string | undefined {
|
|
542
|
+
return this.session.sessionManager.getLeafId() ?? undefined;
|
|
543
|
+
}
|
|
544
|
+
|
|
283
545
|
get isRunning(): boolean {
|
|
284
546
|
return this.session.isStreaming;
|
|
285
547
|
}
|
|
@@ -308,7 +570,7 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
|
|
|
308
570
|
);
|
|
309
571
|
}
|
|
310
572
|
|
|
311
|
-
async
|
|
573
|
+
async queueCoordinatorMessage(message: CoordinatorMessage): Promise<void> {
|
|
312
574
|
await this.session.sendCustomMessage(
|
|
313
575
|
{
|
|
314
576
|
customType: message.customType,
|
|
@@ -343,11 +605,12 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
|
|
|
343
605
|
return snapshotCommittedContext(this.session.messages, this.session.isStreaming);
|
|
344
606
|
}
|
|
345
607
|
|
|
346
|
-
hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string): boolean {
|
|
608
|
+
hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string, deliveryId?: string): boolean {
|
|
347
609
|
return findDeliveryEvidence(
|
|
348
|
-
this.session.sessionManager.
|
|
610
|
+
this.session.sessionManager.getBranch(),
|
|
349
611
|
sourceAgentId,
|
|
350
612
|
sourceTurnId,
|
|
613
|
+
deliveryId,
|
|
351
614
|
);
|
|
352
615
|
}
|
|
353
616
|
|
|
@@ -355,59 +618,9 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
|
|
|
355
618
|
return sumUsage(this.session.messages);
|
|
356
619
|
}
|
|
357
620
|
|
|
358
|
-
async cloneSession(): Promise<{ sessionFile: string; sessionId: string }> {
|
|
359
|
-
const leafId = this.session.sessionManager.getLeafId();
|
|
360
|
-
if (!leafId)
|
|
361
|
-
throw new Error(`Minimal subagents fork clone: ${this.sessionId} has no child leaf`);
|
|
362
|
-
const sessionFile = this.session.sessionManager.createBranchedSession(leafId);
|
|
363
|
-
if (!sessionFile)
|
|
364
|
-
throw new Error(`Minimal subagents fork clone: ${this.sessionId} is not persistent`);
|
|
365
|
-
return { sessionFile, sessionId: this.session.sessionManager.getSessionId() };
|
|
366
|
-
}
|
|
367
|
-
|
|
368
621
|
private async captureTurn(operation: () => Promise<void>): Promise<RuntimeTurnOutcome> {
|
|
369
|
-
const messageStart = this.session.messages.length;
|
|
370
622
|
this.aborted = false;
|
|
371
|
-
|
|
372
|
-
await operation();
|
|
373
|
-
} catch (error) {
|
|
374
|
-
return {
|
|
375
|
-
status: this.aborted ? "cancelled" : "failed",
|
|
376
|
-
output: "",
|
|
377
|
-
error: error instanceof Error ? error.message : String(error),
|
|
378
|
-
};
|
|
379
|
-
}
|
|
380
|
-
const turnMessages = this.session.messages.slice(messageStart);
|
|
381
|
-
const finalAssistant = [...turnMessages]
|
|
382
|
-
.reverse()
|
|
383
|
-
.find((message) => message.role === "assistant");
|
|
384
|
-
if (!finalAssistant || finalAssistant.role !== "assistant") {
|
|
385
|
-
return {
|
|
386
|
-
status: this.aborted ? "cancelled" : "failed",
|
|
387
|
-
output: "",
|
|
388
|
-
error: "No terminal assistant response",
|
|
389
|
-
};
|
|
390
|
-
}
|
|
391
|
-
if (finalAssistant.stopReason === "aborted") {
|
|
392
|
-
return {
|
|
393
|
-
status: "cancelled",
|
|
394
|
-
output: assistantText(finalAssistant),
|
|
395
|
-
error: finalAssistant.errorMessage,
|
|
396
|
-
};
|
|
397
|
-
}
|
|
398
|
-
if (finalAssistant.stopReason === "error") {
|
|
399
|
-
return {
|
|
400
|
-
status: "failed",
|
|
401
|
-
output: assistantText(finalAssistant),
|
|
402
|
-
error: finalAssistant.errorMessage ?? "Provider request failed",
|
|
403
|
-
usage: sumUsage(turnMessages),
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
return {
|
|
407
|
-
status: "completed",
|
|
408
|
-
output: assistantText(finalAssistant),
|
|
409
|
-
usage: sumUsage(turnMessages),
|
|
410
|
-
};
|
|
623
|
+
return captureChildTurnOutcome(this.session, operation, () => this.aborted);
|
|
411
624
|
}
|
|
412
625
|
|
|
413
626
|
private async compactImportedContext(
|
|
@@ -449,7 +662,7 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
|
|
|
449
662
|
auth.auth.headers
|
|
450
663
|
? Object.fromEntries(
|
|
451
664
|
Object.entries(auth.auth.headers).filter(
|
|
452
|
-
(entry): entry is [string, string] =>
|
|
665
|
+
(entry): entry is [string, string] => entry[1] !== null,
|
|
453
666
|
),
|
|
454
667
|
)
|
|
455
668
|
: undefined,
|
|
@@ -483,6 +696,7 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
|
|
483
696
|
private readonly eligibleModelIds: Set<string>;
|
|
484
697
|
private readonly availableToolNames: Set<string>;
|
|
485
698
|
private readonly discoveredToolNames = new Map<string, Promise<Set<string>>>();
|
|
699
|
+
private readonly sessionFileTrash: SessionFileTrashCapability;
|
|
486
700
|
|
|
487
701
|
constructor(private readonly options: PiAgentSessionFactoryOptions) {
|
|
488
702
|
this.modelById = new Map(
|
|
@@ -490,6 +704,7 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
|
|
490
704
|
);
|
|
491
705
|
this.eligibleModelIds = new Set(options.eligibleModelIds);
|
|
492
706
|
this.availableToolNames = new Set(options.availableToolNames);
|
|
707
|
+
this.sessionFileTrash = options.sessionFileTrash ?? execFileSessionTrashCapability;
|
|
493
708
|
}
|
|
494
709
|
|
|
495
710
|
createIdentity(
|
|
@@ -531,27 +746,149 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
|
|
531
746
|
return this.modelById.get(modelId)?.input.includes("image") ?? false;
|
|
532
747
|
}
|
|
533
748
|
|
|
749
|
+
/** Clone one source-owned child leaf with explicit source-root provenance. */
|
|
534
750
|
async cloneSession(agent: PersistedAgent): Promise<PersistedSessionIdentity> {
|
|
535
|
-
|
|
751
|
+
return this.cloneSessionOwnedByRoot(agent, this.options.rootSessionId);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** Recover one proven selected child leaf after the source process handoff was lost. */
|
|
755
|
+
async cloneForkSourceSession(
|
|
756
|
+
agent: PersistedAgent,
|
|
757
|
+
sourceRootSessionId: string,
|
|
758
|
+
): Promise<PersistedSessionIdentity> {
|
|
759
|
+
return this.cloneSessionOwnedByRoot(agent, sourceRootSessionId);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** Bind one verified fork clone exclusively to this factory's destination root. */
|
|
763
|
+
async adoptForkSessionOwnership(
|
|
764
|
+
agent: PersistedAgent,
|
|
765
|
+
sourceRootSessionId: string,
|
|
766
|
+
): Promise<PersistedSessionIdentity> {
|
|
767
|
+
if (!agent.session_file || !agent.session_id) {
|
|
768
|
+
throw new Error(`Minimal subagents fork ownership: ${agent.agent_id} has no clone session`);
|
|
769
|
+
}
|
|
770
|
+
const sessionFile = canonicalPath(agent.session_file);
|
|
771
|
+
const sessionManager = SessionManager.open(
|
|
772
|
+
sessionFile,
|
|
773
|
+
this.options.sessionDir,
|
|
774
|
+
this.options.cwd,
|
|
775
|
+
);
|
|
776
|
+
if (sessionManager.getSessionId() !== agent.session_id) {
|
|
777
|
+
throw new Error(
|
|
778
|
+
`Minimal subagents session identity mismatch: session ID for ${agent.agent_id}`,
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
const branch = sessionManager.getBranch(agent.session_leaf_id);
|
|
782
|
+
const generation = findLatestForkGeneration(branch);
|
|
783
|
+
const identity =
|
|
784
|
+
generation?.identity ??
|
|
785
|
+
findLatestChildSessionRecord(
|
|
786
|
+
branch,
|
|
787
|
+
CHILD_IDENTITY_ENTRY_TYPE,
|
|
788
|
+
ChildSessionIdentityRecordSchema,
|
|
789
|
+
);
|
|
790
|
+
if (
|
|
791
|
+
!identity ||
|
|
792
|
+
identity.original_root_session_id !== sourceRootSessionId ||
|
|
793
|
+
identity.canonical_agent_id !== agent.agent_id ||
|
|
794
|
+
identity.direct_parent_id !== agent.parent_id ||
|
|
795
|
+
identity.created_at !== agent.created_at
|
|
796
|
+
) {
|
|
797
|
+
throw new Error(
|
|
798
|
+
`Minimal subagents session identity mismatch: fork provenance for ${agent.agent_id}`,
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
const provenance = verifyForkCloneProvenance(branch, agent, sourceRootSessionId);
|
|
802
|
+
const existingOwnership = findCurrentForkOwnership(branch, sessionManager.getSessionId());
|
|
803
|
+
if (existingOwnership) {
|
|
804
|
+
if (
|
|
805
|
+
existingOwnership.source_root_session_id !== sourceRootSessionId ||
|
|
806
|
+
existingOwnership.destination_root_session_id !== this.options.rootSessionId ||
|
|
807
|
+
existingOwnership.source_agent_id !== agent.agent_id ||
|
|
808
|
+
existingOwnership.source_session_id !== provenance.source_session_id ||
|
|
809
|
+
existingOwnership.direct_parent_id !== agent.parent_id
|
|
810
|
+
) {
|
|
811
|
+
throw new Error(
|
|
812
|
+
`Minimal subagents session identity mismatch: root owner for ${agent.agent_id}`,
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
} else {
|
|
816
|
+
sessionManager.appendCustomEntry(FORK_OWNERSHIP_ENTRY_TYPE, {
|
|
817
|
+
version: 1,
|
|
818
|
+
source_root_session_id: sourceRootSessionId,
|
|
819
|
+
destination_root_session_id: this.options.rootSessionId,
|
|
820
|
+
source_agent_id: agent.agent_id,
|
|
821
|
+
source_session_id: provenance.source_session_id,
|
|
822
|
+
clone_session_id: sessionManager.getSessionId(),
|
|
823
|
+
direct_parent_id: agent.parent_id,
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
return {
|
|
827
|
+
sessionFile,
|
|
828
|
+
sessionId: sessionManager.getSessionId(),
|
|
829
|
+
sessionLeafId: sessionManager.getLeafId() ?? undefined,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
async trashSession(agent: PersistedAgent): Promise<void> {
|
|
834
|
+
if (!agent.session_file) return;
|
|
835
|
+
const sessionFile = canonicalPath(agent.session_file);
|
|
836
|
+
const sessionManager = SessionManager.open(
|
|
837
|
+
sessionFile,
|
|
838
|
+
this.options.sessionDir,
|
|
839
|
+
this.options.cwd,
|
|
840
|
+
);
|
|
841
|
+
verifyChildSessionIdentity(sessionManager, agent, this.options.rootSessionId);
|
|
842
|
+
const trashError = await this.sessionFileTrash.moveSessionFile(sessionFile);
|
|
843
|
+
if (!trashError || !existsSync(sessionFile)) return;
|
|
844
|
+
|
|
845
|
+
try {
|
|
846
|
+
await unlink(sessionFile);
|
|
847
|
+
} catch (error) {
|
|
848
|
+
const unlinkError = error instanceof Error ? error.message : String(error);
|
|
849
|
+
throw new Error(
|
|
850
|
+
`Minimal subagents session deletion failed for ${sessionFile}: ${unlinkError} (trash: ${trashError.message})`,
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
private async cloneSessionOwnedByRoot(
|
|
856
|
+
agent: PersistedAgent,
|
|
857
|
+
sourceRootSessionId: string,
|
|
858
|
+
): Promise<PersistedSessionIdentity> {
|
|
859
|
+
if (!agent.session_file || !agent.session_id) {
|
|
536
860
|
throw new Error(`Minimal subagents fork clone: ${agent.agent_id} has no source session`);
|
|
537
861
|
}
|
|
538
862
|
const source = SessionManager.open(
|
|
539
|
-
agent.session_file,
|
|
863
|
+
canonicalPath(agent.session_file),
|
|
540
864
|
this.options.sessionDir,
|
|
541
865
|
this.options.cwd,
|
|
542
866
|
);
|
|
543
|
-
|
|
544
|
-
|
|
867
|
+
verifyChildSessionIdentity(source, agent, sourceRootSessionId);
|
|
868
|
+
const leafId = agent.session_leaf_id ?? source.getLeafId();
|
|
869
|
+
if (!leafId || !source.getEntry(leafId))
|
|
545
870
|
throw new Error(`Minimal subagents fork clone: ${agent.agent_id} has no child leaf`);
|
|
546
871
|
const sessionFile = source.createBranchedSession(leafId);
|
|
547
872
|
if (!sessionFile)
|
|
548
873
|
throw new Error(`Minimal subagents fork clone: ${agent.agent_id} is not persistent`);
|
|
549
|
-
|
|
874
|
+
// createBranchedSession mutates this manager to the new session even when Pi defers
|
|
875
|
+
// writing an identity-only branch until its first assistant response.
|
|
876
|
+
const clone = source;
|
|
877
|
+
clone.appendCustomEntry(CHILD_IDENTITY_ENTRY_TYPE, {
|
|
878
|
+
version: 1,
|
|
879
|
+
original_root_session_id: sourceRootSessionId,
|
|
880
|
+
canonical_agent_id: agent.agent_id,
|
|
881
|
+
direct_parent_id: agent.parent_id,
|
|
882
|
+
created_at: agent.created_at,
|
|
883
|
+
});
|
|
884
|
+
clone.appendCustomEntry(FORK_CLONE_ENTRY_TYPE, {
|
|
885
|
+
version: 1,
|
|
886
|
+
source_root_session_id: sourceRootSessionId,
|
|
550
887
|
source_agent_id: agent.agent_id,
|
|
551
888
|
source_session_id: agent.session_id,
|
|
552
889
|
});
|
|
553
890
|
if (!existsSync(sessionFile)) {
|
|
554
|
-
const lines = [
|
|
891
|
+
const lines = [clone.getHeader(), ...clone.getEntries()]
|
|
555
892
|
.filter((entry) => entry !== null)
|
|
556
893
|
.map((entry) => JSON.stringify(entry))
|
|
557
894
|
.join("\n");
|
|
@@ -560,24 +897,11 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
|
|
560
897
|
if (!existsSync(sessionFile)) {
|
|
561
898
|
throw new Error(`Minimal subagents fork clone: clone was not flushed for ${agent.agent_id}`);
|
|
562
899
|
}
|
|
563
|
-
return {
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
const trashArguments = sessionFile.startsWith("-") ? ["--", sessionFile] : [sessionFile];
|
|
569
|
-
execFile("trash", trashArguments, (error) => resolvePromise(error ?? undefined));
|
|
570
|
-
});
|
|
571
|
-
if (!trashError || !existsSync(sessionFile)) return;
|
|
572
|
-
|
|
573
|
-
try {
|
|
574
|
-
await unlink(sessionFile);
|
|
575
|
-
} catch (error) {
|
|
576
|
-
const unlinkError = error instanceof Error ? error.message : String(error);
|
|
577
|
-
throw new Error(
|
|
578
|
-
`Minimal subagents session deletion failed for ${sessionFile}: ${unlinkError} (trash: ${trashError.message})`,
|
|
579
|
-
);
|
|
580
|
-
}
|
|
900
|
+
return {
|
|
901
|
+
sessionFile,
|
|
902
|
+
sessionId: clone.getSessionId(),
|
|
903
|
+
sessionLeafId: clone.getLeafId() ?? undefined,
|
|
904
|
+
};
|
|
581
905
|
}
|
|
582
906
|
|
|
583
907
|
private buildChildSystemPrompt(agent: PersistedAgent): string {
|
|
@@ -674,10 +998,12 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
|
|
674
998
|
modelsPath: resolve(this.options.agentDir, "models.json"),
|
|
675
999
|
});
|
|
676
1000
|
const sessionManager = SessionManager.open(
|
|
677
|
-
agent.session_file,
|
|
1001
|
+
canonicalPath(agent.session_file),
|
|
678
1002
|
this.options.sessionDir,
|
|
679
1003
|
this.options.cwd,
|
|
680
1004
|
);
|
|
1005
|
+
verifyChildSessionIdentity(sessionManager, agent, this.options.rootSessionId);
|
|
1006
|
+
if (agent.session_leaf_id) sessionManager.branch(agent.session_leaf_id);
|
|
681
1007
|
const coordinatorTools = this.options.getCoordinatorTools(agent.agent_id);
|
|
682
1008
|
const allowedToolNames = [
|
|
683
1009
|
...agent.launch_contract.ordinary_tools,
|
|
@@ -21,9 +21,9 @@ export async function shutdownMinimalSubagentsSession(
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
while (true) {
|
|
24
|
+
if (!rootIdleGate.isRootIdle()) await rootIdleGate.waitForRootIdle();
|
|
24
25
|
await coordinator.waitForSettledOperations();
|
|
25
26
|
if (rootIdleGate.isRootIdle()) break;
|
|
26
|
-
await rootIdleGate.waitForRootIdle();
|
|
27
27
|
}
|
|
28
28
|
await coordinator.shutdownAfterSettling();
|
|
29
29
|
}
|