@ian-pascoe/pi-minimal-subagents 0.1.1 → 0.2.1
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 +8 -12
- package/src/minimal-subagents-config.ts +169 -63
- package/src/minimal-subagents-context.ts +7 -1
- package/src/minimal-subagents-coordinator.ts +932 -130
- package/src/minimal-subagents-delivery-ledger.ts +521 -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 +1487 -137
- package/src/minimal-subagents-render-contract.ts +279 -0
- package/src/minimal-subagents-rendering.ts +445 -399
- package/src/minimal-subagents-session-wire.ts +42 -0
- package/src/minimal-subagents-sessions.ts +428 -120
- package/src/minimal-subagents-shutdown.ts +1 -1
- package/src/minimal-subagents-tool-schemas.ts +21 -7
- package/src/minimal-subagents-tools.ts +46 -18
- package/src/minimal-subagents-types.ts +67 -27
- 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,
|
|
@@ -36,7 +53,6 @@ import type {
|
|
|
36
53
|
PersistedAgent,
|
|
37
54
|
PersistedSessionIdentity,
|
|
38
55
|
ProjectContextMode,
|
|
39
|
-
RuntimeCreationRequest,
|
|
40
56
|
RuntimeProfile,
|
|
41
57
|
RuntimeTurnOutcome,
|
|
42
58
|
} from "./minimal-subagents-types.js";
|
|
@@ -69,6 +85,19 @@ interface ChildResourceLoaderOptionsInput {
|
|
|
69
85
|
settingsManager?: SettingsManager;
|
|
70
86
|
}
|
|
71
87
|
|
|
88
|
+
/** Moves one verified child session file to trash and reports command unavailability. */
|
|
89
|
+
export interface SessionFileTrashCapability {
|
|
90
|
+
moveSessionFile(sessionFile: string): Promise<Error | undefined>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const execFileSessionTrashCapability: SessionFileTrashCapability = {
|
|
94
|
+
moveSessionFile: (sessionFile) =>
|
|
95
|
+
new Promise((resolvePromise) => {
|
|
96
|
+
const trashArguments = sessionFile.startsWith("-") ? ["--", sessionFile] : [sessionFile];
|
|
97
|
+
execFile("trash", trashArguments, (cause) => resolvePromise(cause ?? undefined));
|
|
98
|
+
}),
|
|
99
|
+
};
|
|
100
|
+
|
|
72
101
|
/** Configures root ownership, model scope, child resources, and coordinator tool injection for Pi sessions. */
|
|
73
102
|
export interface PiAgentSessionFactoryOptions {
|
|
74
103
|
cwd: string;
|
|
@@ -82,6 +111,7 @@ export interface PiAgentSessionFactoryOptions {
|
|
|
82
111
|
availableToolNames: readonly string[];
|
|
83
112
|
projectTrusted: boolean;
|
|
84
113
|
maxSubagentDepth?: number;
|
|
114
|
+
sessionFileTrash?: SessionFileTrashCapability;
|
|
85
115
|
getCoordinatorTools: (callerId: string) => ToolDefinition[];
|
|
86
116
|
onChildSessionActivity?: () => void;
|
|
87
117
|
}
|
|
@@ -123,6 +153,7 @@ function appendImportedMessage(sessionManager: SessionManager, message: AgentMes
|
|
|
123
153
|
);
|
|
124
154
|
return;
|
|
125
155
|
}
|
|
156
|
+
// SAFETY: AgentMessage is Pi's broader message union; non-session variants were handled above.
|
|
126
157
|
sessionManager.appendMessage(message as Parameters<SessionManager["appendMessage"]>[0]);
|
|
127
158
|
}
|
|
128
159
|
|
|
@@ -159,7 +190,148 @@ export function createPersistentChildIdentity(
|
|
|
159
190
|
writeFileSync(sessionFile, `${lines}\n`, "utf8");
|
|
160
191
|
sessionManager = SessionManager.open(sessionFile, options.sessionDir, options.cwd);
|
|
161
192
|
}
|
|
162
|
-
return {
|
|
193
|
+
return {
|
|
194
|
+
sessionFile,
|
|
195
|
+
sessionId: sessionManager.getSessionId(),
|
|
196
|
+
sessionLeafId: sessionManager.getLeafId() ?? undefined,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function findLatestChildSessionRecord<TRecordSchema extends TSchema>(
|
|
201
|
+
entries: ReturnType<SessionManager["getBranch"]>,
|
|
202
|
+
customType: string,
|
|
203
|
+
schema: TRecordSchema,
|
|
204
|
+
): Static<TRecordSchema> | undefined {
|
|
205
|
+
return entries.findLast(
|
|
206
|
+
(
|
|
207
|
+
entry,
|
|
208
|
+
): entry is Extract<SessionEntry, { type: "custom" }> & {
|
|
209
|
+
data: Static<TRecordSchema>;
|
|
210
|
+
} =>
|
|
211
|
+
entry.type === "custom" && entry.customType === customType && Value.Check(schema, entry.data),
|
|
212
|
+
)?.data;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function findLatestForkGeneration(
|
|
216
|
+
entries: ReturnType<SessionManager["getBranch"]>,
|
|
217
|
+
): { identity: ChildSessionIdentityRecord; provenance: ForkCloneProvenanceRecord } | undefined {
|
|
218
|
+
for (let provenanceIndex = entries.length - 1; provenanceIndex >= 0; provenanceIndex--) {
|
|
219
|
+
const provenanceEntry = entries[provenanceIndex];
|
|
220
|
+
if (
|
|
221
|
+
!provenanceEntry ||
|
|
222
|
+
provenanceEntry.type !== "custom" ||
|
|
223
|
+
provenanceEntry.customType !== FORK_CLONE_ENTRY_TYPE
|
|
224
|
+
) {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (!Value.Check(ForkCloneProvenanceRecordSchema, provenanceEntry.data)) continue;
|
|
228
|
+
const provenance = provenanceEntry.data;
|
|
229
|
+
for (let identityIndex = provenanceIndex - 1; identityIndex >= 0; identityIndex--) {
|
|
230
|
+
const identityEntry = entries[identityIndex];
|
|
231
|
+
if (
|
|
232
|
+
!identityEntry ||
|
|
233
|
+
identityEntry.type !== "custom" ||
|
|
234
|
+
identityEntry.customType !== CHILD_IDENTITY_ENTRY_TYPE
|
|
235
|
+
) {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (Value.Check(ChildSessionIdentityRecordSchema, identityEntry.data)) {
|
|
239
|
+
return { identity: identityEntry.data, provenance };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function findCurrentForkOwnership(
|
|
248
|
+
entries: ReturnType<SessionManager["getBranch"]>,
|
|
249
|
+
cloneSessionId: string,
|
|
250
|
+
): ForkOwnershipRecord | undefined {
|
|
251
|
+
return entries.findLast(
|
|
252
|
+
(
|
|
253
|
+
entry,
|
|
254
|
+
): entry is Extract<SessionEntry, { type: "custom" }> & {
|
|
255
|
+
data: ForkOwnershipRecord;
|
|
256
|
+
} =>
|
|
257
|
+
entry.type === "custom" &&
|
|
258
|
+
entry.customType === FORK_OWNERSHIP_ENTRY_TYPE &&
|
|
259
|
+
Value.Check(ForkOwnershipRecordSchema, entry.data) &&
|
|
260
|
+
entry.data.clone_session_id === cloneSessionId,
|
|
261
|
+
)?.data;
|
|
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;
|
|
@@ -269,15 +527,8 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
|
|
|
269
527
|
});
|
|
270
528
|
}
|
|
271
529
|
|
|
272
|
-
get
|
|
273
|
-
|
|
274
|
-
if (!sessionFile)
|
|
275
|
-
throw new Error("Minimal subagents child runtime lost its persistent session file");
|
|
276
|
-
return sessionFile;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
get sessionId(): string {
|
|
280
|
-
return this.session.sessionId;
|
|
530
|
+
get sessionLeafId(): string | undefined {
|
|
531
|
+
return this.session.sessionManager.getLeafId() ?? undefined;
|
|
281
532
|
}
|
|
282
533
|
|
|
283
534
|
get isRunning(): boolean {
|
|
@@ -308,7 +559,7 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
|
|
|
308
559
|
);
|
|
309
560
|
}
|
|
310
561
|
|
|
311
|
-
async
|
|
562
|
+
async queueCoordinatorMessage(message: CoordinatorMessage): Promise<void> {
|
|
312
563
|
await this.session.sendCustomMessage(
|
|
313
564
|
{
|
|
314
565
|
customType: message.customType,
|
|
@@ -343,11 +594,12 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
|
|
|
343
594
|
return snapshotCommittedContext(this.session.messages, this.session.isStreaming);
|
|
344
595
|
}
|
|
345
596
|
|
|
346
|
-
hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string): boolean {
|
|
597
|
+
hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string, deliveryId?: string): boolean {
|
|
347
598
|
return findDeliveryEvidence(
|
|
348
|
-
this.session.sessionManager.
|
|
599
|
+
this.session.sessionManager.getBranch(),
|
|
349
600
|
sourceAgentId,
|
|
350
601
|
sourceTurnId,
|
|
602
|
+
deliveryId,
|
|
351
603
|
);
|
|
352
604
|
}
|
|
353
605
|
|
|
@@ -355,59 +607,9 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
|
|
|
355
607
|
return sumUsage(this.session.messages);
|
|
356
608
|
}
|
|
357
609
|
|
|
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
610
|
private async captureTurn(operation: () => Promise<void>): Promise<RuntimeTurnOutcome> {
|
|
369
|
-
const messageStart = this.session.messages.length;
|
|
370
611
|
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
|
-
};
|
|
612
|
+
return captureChildTurnOutcome(this.session, operation, () => this.aborted);
|
|
411
613
|
}
|
|
412
614
|
|
|
413
615
|
private async compactImportedContext(
|
|
@@ -449,7 +651,7 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
|
|
|
449
651
|
auth.auth.headers
|
|
450
652
|
? Object.fromEntries(
|
|
451
653
|
Object.entries(auth.auth.headers).filter(
|
|
452
|
-
(entry): entry is [string, string] =>
|
|
654
|
+
(entry): entry is [string, string] => entry[1] !== null,
|
|
453
655
|
),
|
|
454
656
|
)
|
|
455
657
|
: undefined,
|
|
@@ -483,6 +685,7 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
|
|
483
685
|
private readonly eligibleModelIds: Set<string>;
|
|
484
686
|
private readonly availableToolNames: Set<string>;
|
|
485
687
|
private readonly discoveredToolNames = new Map<string, Promise<Set<string>>>();
|
|
688
|
+
private readonly sessionFileTrash: SessionFileTrashCapability;
|
|
486
689
|
|
|
487
690
|
constructor(private readonly options: PiAgentSessionFactoryOptions) {
|
|
488
691
|
this.modelById = new Map(
|
|
@@ -490,6 +693,7 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
|
|
490
693
|
);
|
|
491
694
|
this.eligibleModelIds = new Set(options.eligibleModelIds);
|
|
492
695
|
this.availableToolNames = new Set(options.availableToolNames);
|
|
696
|
+
this.sessionFileTrash = options.sessionFileTrash ?? execFileSessionTrashCapability;
|
|
493
697
|
}
|
|
494
698
|
|
|
495
699
|
createIdentity(
|
|
@@ -505,14 +709,6 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
|
|
505
709
|
});
|
|
506
710
|
}
|
|
507
711
|
|
|
508
|
-
createRuntime(request: RuntimeCreationRequest): Promise<ChildAgentRuntime> {
|
|
509
|
-
return this.openRuntime(request.agent);
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
restoreRuntime(agent: PersistedAgent): Promise<ChildAgentRuntime> {
|
|
513
|
-
return this.openRuntime(agent);
|
|
514
|
-
}
|
|
515
|
-
|
|
516
712
|
resolveLaunchMissingDependencies(agent: PersistedAgent): Promise<string[]> {
|
|
517
713
|
return this.findMissingDependencies(agent, false);
|
|
518
714
|
}
|
|
@@ -531,27 +727,149 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
|
|
531
727
|
return this.modelById.get(modelId)?.input.includes("image") ?? false;
|
|
532
728
|
}
|
|
533
729
|
|
|
730
|
+
/** Clone one source-owned child leaf with explicit source-root provenance. */
|
|
534
731
|
async cloneSession(agent: PersistedAgent): Promise<PersistedSessionIdentity> {
|
|
535
|
-
|
|
732
|
+
return this.cloneSessionOwnedByRoot(agent, this.options.rootSessionId);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** Recover one proven selected child leaf after the source process handoff was lost. */
|
|
736
|
+
async cloneForkSourceSession(
|
|
737
|
+
agent: PersistedAgent,
|
|
738
|
+
sourceRootSessionId: string,
|
|
739
|
+
): Promise<PersistedSessionIdentity> {
|
|
740
|
+
return this.cloneSessionOwnedByRoot(agent, sourceRootSessionId);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** Bind one verified fork clone exclusively to this factory's destination root. */
|
|
744
|
+
async adoptForkSessionOwnership(
|
|
745
|
+
agent: PersistedAgent,
|
|
746
|
+
sourceRootSessionId: string,
|
|
747
|
+
): Promise<PersistedSessionIdentity> {
|
|
748
|
+
if (!agent.session_file || !agent.session_id) {
|
|
749
|
+
throw new Error(`Minimal subagents fork ownership: ${agent.agent_id} has no clone session`);
|
|
750
|
+
}
|
|
751
|
+
const sessionFile = canonicalPath(agent.session_file);
|
|
752
|
+
const sessionManager = SessionManager.open(
|
|
753
|
+
sessionFile,
|
|
754
|
+
this.options.sessionDir,
|
|
755
|
+
this.options.cwd,
|
|
756
|
+
);
|
|
757
|
+
if (sessionManager.getSessionId() !== agent.session_id) {
|
|
758
|
+
throw new Error(
|
|
759
|
+
`Minimal subagents session identity mismatch: session ID for ${agent.agent_id}`,
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
const branch = sessionManager.getBranch(agent.session_leaf_id);
|
|
763
|
+
const generation = findLatestForkGeneration(branch);
|
|
764
|
+
const identity =
|
|
765
|
+
generation?.identity ??
|
|
766
|
+
findLatestChildSessionRecord(
|
|
767
|
+
branch,
|
|
768
|
+
CHILD_IDENTITY_ENTRY_TYPE,
|
|
769
|
+
ChildSessionIdentityRecordSchema,
|
|
770
|
+
);
|
|
771
|
+
if (
|
|
772
|
+
!identity ||
|
|
773
|
+
identity.original_root_session_id !== sourceRootSessionId ||
|
|
774
|
+
identity.canonical_agent_id !== agent.agent_id ||
|
|
775
|
+
identity.direct_parent_id !== agent.parent_id ||
|
|
776
|
+
identity.created_at !== agent.created_at
|
|
777
|
+
) {
|
|
778
|
+
throw new Error(
|
|
779
|
+
`Minimal subagents session identity mismatch: fork provenance for ${agent.agent_id}`,
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
const provenance = verifyForkCloneProvenance(branch, agent, sourceRootSessionId);
|
|
783
|
+
const existingOwnership = findCurrentForkOwnership(branch, sessionManager.getSessionId());
|
|
784
|
+
if (existingOwnership) {
|
|
785
|
+
if (
|
|
786
|
+
existingOwnership.source_root_session_id !== sourceRootSessionId ||
|
|
787
|
+
existingOwnership.destination_root_session_id !== this.options.rootSessionId ||
|
|
788
|
+
existingOwnership.source_agent_id !== agent.agent_id ||
|
|
789
|
+
existingOwnership.source_session_id !== provenance.source_session_id ||
|
|
790
|
+
existingOwnership.direct_parent_id !== agent.parent_id
|
|
791
|
+
) {
|
|
792
|
+
throw new Error(
|
|
793
|
+
`Minimal subagents session identity mismatch: root owner for ${agent.agent_id}`,
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
} else {
|
|
797
|
+
sessionManager.appendCustomEntry(FORK_OWNERSHIP_ENTRY_TYPE, {
|
|
798
|
+
version: 1,
|
|
799
|
+
source_root_session_id: sourceRootSessionId,
|
|
800
|
+
destination_root_session_id: this.options.rootSessionId,
|
|
801
|
+
source_agent_id: agent.agent_id,
|
|
802
|
+
source_session_id: provenance.source_session_id,
|
|
803
|
+
clone_session_id: sessionManager.getSessionId(),
|
|
804
|
+
direct_parent_id: agent.parent_id,
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
return {
|
|
808
|
+
sessionFile,
|
|
809
|
+
sessionId: sessionManager.getSessionId(),
|
|
810
|
+
sessionLeafId: sessionManager.getLeafId() ?? undefined,
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async trashSession(agent: PersistedAgent): Promise<void> {
|
|
815
|
+
if (!agent.session_file) return;
|
|
816
|
+
const sessionFile = canonicalPath(agent.session_file);
|
|
817
|
+
const sessionManager = SessionManager.open(
|
|
818
|
+
sessionFile,
|
|
819
|
+
this.options.sessionDir,
|
|
820
|
+
this.options.cwd,
|
|
821
|
+
);
|
|
822
|
+
verifyChildSessionIdentity(sessionManager, agent, this.options.rootSessionId);
|
|
823
|
+
const trashError = await this.sessionFileTrash.moveSessionFile(sessionFile);
|
|
824
|
+
if (!trashError || !existsSync(sessionFile)) return;
|
|
825
|
+
|
|
826
|
+
try {
|
|
827
|
+
await unlink(sessionFile);
|
|
828
|
+
} catch (error) {
|
|
829
|
+
const unlinkError = error instanceof Error ? error.message : String(error);
|
|
830
|
+
throw new Error(
|
|
831
|
+
`Minimal subagents session deletion failed for ${sessionFile}: ${unlinkError} (trash: ${trashError.message})`,
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
private async cloneSessionOwnedByRoot(
|
|
837
|
+
agent: PersistedAgent,
|
|
838
|
+
sourceRootSessionId: string,
|
|
839
|
+
): Promise<PersistedSessionIdentity> {
|
|
840
|
+
if (!agent.session_file || !agent.session_id) {
|
|
536
841
|
throw new Error(`Minimal subagents fork clone: ${agent.agent_id} has no source session`);
|
|
537
842
|
}
|
|
538
843
|
const source = SessionManager.open(
|
|
539
|
-
agent.session_file,
|
|
844
|
+
canonicalPath(agent.session_file),
|
|
540
845
|
this.options.sessionDir,
|
|
541
846
|
this.options.cwd,
|
|
542
847
|
);
|
|
543
|
-
|
|
544
|
-
|
|
848
|
+
verifyChildSessionIdentity(source, agent, sourceRootSessionId);
|
|
849
|
+
const leafId = agent.session_leaf_id ?? source.getLeafId();
|
|
850
|
+
if (!leafId || !source.getEntry(leafId))
|
|
545
851
|
throw new Error(`Minimal subagents fork clone: ${agent.agent_id} has no child leaf`);
|
|
546
852
|
const sessionFile = source.createBranchedSession(leafId);
|
|
547
853
|
if (!sessionFile)
|
|
548
854
|
throw new Error(`Minimal subagents fork clone: ${agent.agent_id} is not persistent`);
|
|
549
|
-
|
|
855
|
+
// createBranchedSession mutates this manager to the new session even when Pi defers
|
|
856
|
+
// writing an identity-only branch until its first assistant response.
|
|
857
|
+
const clone = source;
|
|
858
|
+
clone.appendCustomEntry(CHILD_IDENTITY_ENTRY_TYPE, {
|
|
859
|
+
version: 1,
|
|
860
|
+
original_root_session_id: sourceRootSessionId,
|
|
861
|
+
canonical_agent_id: agent.agent_id,
|
|
862
|
+
direct_parent_id: agent.parent_id,
|
|
863
|
+
created_at: agent.created_at,
|
|
864
|
+
});
|
|
865
|
+
clone.appendCustomEntry(FORK_CLONE_ENTRY_TYPE, {
|
|
866
|
+
version: 1,
|
|
867
|
+
source_root_session_id: sourceRootSessionId,
|
|
550
868
|
source_agent_id: agent.agent_id,
|
|
551
869
|
source_session_id: agent.session_id,
|
|
552
870
|
});
|
|
553
871
|
if (!existsSync(sessionFile)) {
|
|
554
|
-
const lines = [
|
|
872
|
+
const lines = [clone.getHeader(), ...clone.getEntries()]
|
|
555
873
|
.filter((entry) => entry !== null)
|
|
556
874
|
.map((entry) => JSON.stringify(entry))
|
|
557
875
|
.join("\n");
|
|
@@ -560,24 +878,11 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
|
|
560
878
|
if (!existsSync(sessionFile)) {
|
|
561
879
|
throw new Error(`Minimal subagents fork clone: clone was not flushed for ${agent.agent_id}`);
|
|
562
880
|
}
|
|
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
|
-
}
|
|
881
|
+
return {
|
|
882
|
+
sessionFile,
|
|
883
|
+
sessionId: clone.getSessionId(),
|
|
884
|
+
sessionLeafId: clone.getLeafId() ?? undefined,
|
|
885
|
+
};
|
|
581
886
|
}
|
|
582
887
|
|
|
583
888
|
private buildChildSystemPrompt(agent: PersistedAgent): string {
|
|
@@ -643,7 +948,8 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
|
|
643
948
|
return discovery;
|
|
644
949
|
}
|
|
645
950
|
|
|
646
|
-
|
|
951
|
+
/** Open one verified persisted Child Agent runtime for launch or restoration. */
|
|
952
|
+
async openRuntime(agent: PersistedAgent): Promise<ChildAgentRuntime> {
|
|
647
953
|
if (!agent.session_file)
|
|
648
954
|
throw new Error(`Minimal subagents restore: ${agent.agent_id} has no session file`);
|
|
649
955
|
const model = this.modelById.get(agent.launch_contract.model);
|
|
@@ -674,10 +980,12 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
|
|
674
980
|
modelsPath: resolve(this.options.agentDir, "models.json"),
|
|
675
981
|
});
|
|
676
982
|
const sessionManager = SessionManager.open(
|
|
677
|
-
agent.session_file,
|
|
983
|
+
canonicalPath(agent.session_file),
|
|
678
984
|
this.options.sessionDir,
|
|
679
985
|
this.options.cwd,
|
|
680
986
|
);
|
|
987
|
+
verifyChildSessionIdentity(sessionManager, agent, this.options.rootSessionId);
|
|
988
|
+
if (agent.session_leaf_id) sessionManager.branch(agent.session_leaf_id);
|
|
681
989
|
const coordinatorTools = this.options.getCoordinatorTools(agent.agent_id);
|
|
682
990
|
const allowedToolNames = [
|
|
683
991
|
...agent.launch_contract.ordinary_tools,
|