@ian-pascoe/pi-minimal-subagents 0.1.0 → 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 +77 -9
- package/package.json +6 -6
- package/src/minimal-subagents-capabilities.ts +8 -8
- package/src/minimal-subagents-config.ts +222 -73
- package/src/minimal-subagents-context.ts +7 -1
- package/src/minimal-subagents-coordinator.ts +943 -116
- 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 +437 -101
- package/src/minimal-subagents-shutdown.ts +1 -1
- package/src/minimal-subagents-tool-schemas.ts +21 -7
- package/src/minimal-subagents-tools.ts +75 -25
- package/src/minimal-subagents-types.ts +76 -21
- package/src/minimal-subagents-ui.ts +214 -23
|
@@ -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,
|
|
@@ -37,6 +54,7 @@ import type {
|
|
|
37
54
|
PersistedSessionIdentity,
|
|
38
55
|
ProjectContextMode,
|
|
39
56
|
RuntimeCreationRequest,
|
|
57
|
+
RuntimeProfile,
|
|
40
58
|
RuntimeTurnOutcome,
|
|
41
59
|
} from "./minimal-subagents-types.js";
|
|
42
60
|
|
|
@@ -68,6 +86,19 @@ interface ChildResourceLoaderOptionsInput {
|
|
|
68
86
|
settingsManager?: SettingsManager;
|
|
69
87
|
}
|
|
70
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
|
+
|
|
71
102
|
/** Configures root ownership, model scope, child resources, and coordinator tool injection for Pi sessions. */
|
|
72
103
|
export interface PiAgentSessionFactoryOptions {
|
|
73
104
|
cwd: string;
|
|
@@ -81,6 +112,7 @@ export interface PiAgentSessionFactoryOptions {
|
|
|
81
112
|
availableToolNames: readonly string[];
|
|
82
113
|
projectTrusted: boolean;
|
|
83
114
|
maxSubagentDepth?: number;
|
|
115
|
+
sessionFileTrash?: SessionFileTrashCapability;
|
|
84
116
|
getCoordinatorTools: (callerId: string) => ToolDefinition[];
|
|
85
117
|
onChildSessionActivity?: () => void;
|
|
86
118
|
}
|
|
@@ -122,6 +154,7 @@ function appendImportedMessage(sessionManager: SessionManager, message: AgentMes
|
|
|
122
154
|
);
|
|
123
155
|
return;
|
|
124
156
|
}
|
|
157
|
+
// SAFETY: AgentMessage is Pi's broader message union; non-session variants were handled above.
|
|
125
158
|
sessionManager.appendMessage(message as Parameters<SessionManager["appendMessage"]>[0]);
|
|
126
159
|
}
|
|
127
160
|
|
|
@@ -158,7 +191,147 @@ export function createPersistentChildIdentity(
|
|
|
158
191
|
writeFileSync(sessionFile, `${lines}\n`, "utf8");
|
|
159
192
|
sessionManager = SessionManager.open(sessionFile, options.sessionDir, options.cwd);
|
|
160
193
|
}
|
|
161
|
-
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
|
+
}
|
|
162
335
|
}
|
|
163
336
|
|
|
164
337
|
/** Build child resources while filtering recursive coordinator loading and honoring project-context omission. */
|
|
@@ -205,29 +378,44 @@ export function createChildResourceLoaderOptions(
|
|
|
205
378
|
|
|
206
379
|
/** Find durable keyed evidence for exactly-once wait or custom-result delivery. */
|
|
207
380
|
export function findDeliveryEvidence(
|
|
208
|
-
entries: readonly
|
|
381
|
+
entries: readonly SessionEntry[],
|
|
209
382
|
sourceAgentId: string,
|
|
210
383
|
sourceTurnId: string,
|
|
384
|
+
deliveryId?: string,
|
|
211
385
|
): boolean {
|
|
212
386
|
return entries.some((entry) => {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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;
|
|
231
419
|
});
|
|
232
420
|
}
|
|
233
421
|
|
|
@@ -247,6 +435,77 @@ function assistantText(message: AgentMessage | undefined): string {
|
|
|
247
435
|
.join("\n");
|
|
248
436
|
}
|
|
249
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
|
+
|
|
250
509
|
class PiChildAgentRuntime implements ChildAgentRuntime {
|
|
251
510
|
private aborted = false;
|
|
252
511
|
private readonly unsubscribe: () => void;
|
|
@@ -279,6 +538,10 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
|
|
|
279
538
|
return this.session.sessionId;
|
|
280
539
|
}
|
|
281
540
|
|
|
541
|
+
get sessionLeafId(): string | undefined {
|
|
542
|
+
return this.session.sessionManager.getLeafId() ?? undefined;
|
|
543
|
+
}
|
|
544
|
+
|
|
282
545
|
get isRunning(): boolean {
|
|
283
546
|
return this.session.isStreaming;
|
|
284
547
|
}
|
|
@@ -307,7 +570,7 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
|
|
|
307
570
|
);
|
|
308
571
|
}
|
|
309
572
|
|
|
310
|
-
async
|
|
573
|
+
async queueCoordinatorMessage(message: CoordinatorMessage): Promise<void> {
|
|
311
574
|
await this.session.sendCustomMessage(
|
|
312
575
|
{
|
|
313
576
|
customType: message.customType,
|
|
@@ -329,15 +592,25 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
|
|
|
329
592
|
this.session.dispose();
|
|
330
593
|
}
|
|
331
594
|
|
|
595
|
+
getRuntimeProfile(): RuntimeProfile | undefined {
|
|
596
|
+
const model = this.session.model;
|
|
597
|
+
if (!model) return undefined;
|
|
598
|
+
return {
|
|
599
|
+
model: `${model.provider}/${model.id}`,
|
|
600
|
+
thinking_level: this.session.thinkingLevel,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
332
604
|
snapshotCommittedMessages(): AgentMessage[] {
|
|
333
605
|
return snapshotCommittedContext(this.session.messages, this.session.isStreaming);
|
|
334
606
|
}
|
|
335
607
|
|
|
336
|
-
hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string): boolean {
|
|
608
|
+
hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string, deliveryId?: string): boolean {
|
|
337
609
|
return findDeliveryEvidence(
|
|
338
|
-
this.session.sessionManager.
|
|
610
|
+
this.session.sessionManager.getBranch(),
|
|
339
611
|
sourceAgentId,
|
|
340
612
|
sourceTurnId,
|
|
613
|
+
deliveryId,
|
|
341
614
|
);
|
|
342
615
|
}
|
|
343
616
|
|
|
@@ -345,59 +618,9 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
|
|
|
345
618
|
return sumUsage(this.session.messages);
|
|
346
619
|
}
|
|
347
620
|
|
|
348
|
-
async cloneSession(): Promise<{ sessionFile: string; sessionId: string }> {
|
|
349
|
-
const leafId = this.session.sessionManager.getLeafId();
|
|
350
|
-
if (!leafId)
|
|
351
|
-
throw new Error(`Minimal subagents fork clone: ${this.sessionId} has no child leaf`);
|
|
352
|
-
const sessionFile = this.session.sessionManager.createBranchedSession(leafId);
|
|
353
|
-
if (!sessionFile)
|
|
354
|
-
throw new Error(`Minimal subagents fork clone: ${this.sessionId} is not persistent`);
|
|
355
|
-
return { sessionFile, sessionId: this.session.sessionManager.getSessionId() };
|
|
356
|
-
}
|
|
357
|
-
|
|
358
621
|
private async captureTurn(operation: () => Promise<void>): Promise<RuntimeTurnOutcome> {
|
|
359
|
-
const messageStart = this.session.messages.length;
|
|
360
622
|
this.aborted = false;
|
|
361
|
-
|
|
362
|
-
await operation();
|
|
363
|
-
} catch (error) {
|
|
364
|
-
return {
|
|
365
|
-
status: this.aborted ? "cancelled" : "failed",
|
|
366
|
-
output: "",
|
|
367
|
-
error: error instanceof Error ? error.message : String(error),
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
const turnMessages = this.session.messages.slice(messageStart);
|
|
371
|
-
const finalAssistant = [...turnMessages]
|
|
372
|
-
.reverse()
|
|
373
|
-
.find((message) => message.role === "assistant");
|
|
374
|
-
if (!finalAssistant || finalAssistant.role !== "assistant") {
|
|
375
|
-
return {
|
|
376
|
-
status: this.aborted ? "cancelled" : "failed",
|
|
377
|
-
output: "",
|
|
378
|
-
error: "No terminal assistant response",
|
|
379
|
-
};
|
|
380
|
-
}
|
|
381
|
-
if (finalAssistant.stopReason === "aborted") {
|
|
382
|
-
return {
|
|
383
|
-
status: "cancelled",
|
|
384
|
-
output: assistantText(finalAssistant),
|
|
385
|
-
error: finalAssistant.errorMessage,
|
|
386
|
-
};
|
|
387
|
-
}
|
|
388
|
-
if (finalAssistant.stopReason === "error") {
|
|
389
|
-
return {
|
|
390
|
-
status: "failed",
|
|
391
|
-
output: assistantText(finalAssistant),
|
|
392
|
-
error: finalAssistant.errorMessage ?? "Provider request failed",
|
|
393
|
-
usage: sumUsage(turnMessages),
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
|
-
return {
|
|
397
|
-
status: "completed",
|
|
398
|
-
output: assistantText(finalAssistant),
|
|
399
|
-
usage: sumUsage(turnMessages),
|
|
400
|
-
};
|
|
623
|
+
return captureChildTurnOutcome(this.session, operation, () => this.aborted);
|
|
401
624
|
}
|
|
402
625
|
|
|
403
626
|
private async compactImportedContext(
|
|
@@ -439,7 +662,7 @@ class PiChildAgentRuntime implements ChildAgentRuntime {
|
|
|
439
662
|
auth.auth.headers
|
|
440
663
|
? Object.fromEntries(
|
|
441
664
|
Object.entries(auth.auth.headers).filter(
|
|
442
|
-
(entry): entry is [string, string] =>
|
|
665
|
+
(entry): entry is [string, string] => entry[1] !== null,
|
|
443
666
|
),
|
|
444
667
|
)
|
|
445
668
|
: undefined,
|
|
@@ -473,6 +696,7 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
|
|
473
696
|
private readonly eligibleModelIds: Set<string>;
|
|
474
697
|
private readonly availableToolNames: Set<string>;
|
|
475
698
|
private readonly discoveredToolNames = new Map<string, Promise<Set<string>>>();
|
|
699
|
+
private readonly sessionFileTrash: SessionFileTrashCapability;
|
|
476
700
|
|
|
477
701
|
constructor(private readonly options: PiAgentSessionFactoryOptions) {
|
|
478
702
|
this.modelById = new Map(
|
|
@@ -480,6 +704,7 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
|
|
480
704
|
);
|
|
481
705
|
this.eligibleModelIds = new Set(options.eligibleModelIds);
|
|
482
706
|
this.availableToolNames = new Set(options.availableToolNames);
|
|
707
|
+
this.sessionFileTrash = options.sessionFileTrash ?? execFileSessionTrashCapability;
|
|
483
708
|
}
|
|
484
709
|
|
|
485
710
|
createIdentity(
|
|
@@ -521,27 +746,149 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
|
|
521
746
|
return this.modelById.get(modelId)?.input.includes("image") ?? false;
|
|
522
747
|
}
|
|
523
748
|
|
|
749
|
+
/** Clone one source-owned child leaf with explicit source-root provenance. */
|
|
524
750
|
async cloneSession(agent: PersistedAgent): Promise<PersistedSessionIdentity> {
|
|
525
|
-
|
|
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) {
|
|
526
860
|
throw new Error(`Minimal subagents fork clone: ${agent.agent_id} has no source session`);
|
|
527
861
|
}
|
|
528
862
|
const source = SessionManager.open(
|
|
529
|
-
agent.session_file,
|
|
863
|
+
canonicalPath(agent.session_file),
|
|
530
864
|
this.options.sessionDir,
|
|
531
865
|
this.options.cwd,
|
|
532
866
|
);
|
|
533
|
-
|
|
534
|
-
|
|
867
|
+
verifyChildSessionIdentity(source, agent, sourceRootSessionId);
|
|
868
|
+
const leafId = agent.session_leaf_id ?? source.getLeafId();
|
|
869
|
+
if (!leafId || !source.getEntry(leafId))
|
|
535
870
|
throw new Error(`Minimal subagents fork clone: ${agent.agent_id} has no child leaf`);
|
|
536
871
|
const sessionFile = source.createBranchedSession(leafId);
|
|
537
872
|
if (!sessionFile)
|
|
538
873
|
throw new Error(`Minimal subagents fork clone: ${agent.agent_id} is not persistent`);
|
|
539
|
-
|
|
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,
|
|
540
887
|
source_agent_id: agent.agent_id,
|
|
541
888
|
source_session_id: agent.session_id,
|
|
542
889
|
});
|
|
543
890
|
if (!existsSync(sessionFile)) {
|
|
544
|
-
const lines = [
|
|
891
|
+
const lines = [clone.getHeader(), ...clone.getEntries()]
|
|
545
892
|
.filter((entry) => entry !== null)
|
|
546
893
|
.map((entry) => JSON.stringify(entry))
|
|
547
894
|
.join("\n");
|
|
@@ -550,24 +897,11 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
|
|
550
897
|
if (!existsSync(sessionFile)) {
|
|
551
898
|
throw new Error(`Minimal subagents fork clone: clone was not flushed for ${agent.agent_id}`);
|
|
552
899
|
}
|
|
553
|
-
return {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
const trashArguments = sessionFile.startsWith("-") ? ["--", sessionFile] : [sessionFile];
|
|
559
|
-
execFile("trash", trashArguments, (error) => resolvePromise(error ?? undefined));
|
|
560
|
-
});
|
|
561
|
-
if (!trashError || !existsSync(sessionFile)) return;
|
|
562
|
-
|
|
563
|
-
try {
|
|
564
|
-
await unlink(sessionFile);
|
|
565
|
-
} catch (error) {
|
|
566
|
-
const unlinkError = error instanceof Error ? error.message : String(error);
|
|
567
|
-
throw new Error(
|
|
568
|
-
`Minimal subagents session deletion failed for ${sessionFile}: ${unlinkError} (trash: ${trashError.message})`,
|
|
569
|
-
);
|
|
570
|
-
}
|
|
900
|
+
return {
|
|
901
|
+
sessionFile,
|
|
902
|
+
sessionId: clone.getSessionId(),
|
|
903
|
+
sessionLeafId: clone.getLeafId() ?? undefined,
|
|
904
|
+
};
|
|
571
905
|
}
|
|
572
906
|
|
|
573
907
|
private buildChildSystemPrompt(agent: PersistedAgent): string {
|
|
@@ -664,10 +998,12 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
|
|
664
998
|
modelsPath: resolve(this.options.agentDir, "models.json"),
|
|
665
999
|
});
|
|
666
1000
|
const sessionManager = SessionManager.open(
|
|
667
|
-
agent.session_file,
|
|
1001
|
+
canonicalPath(agent.session_file),
|
|
668
1002
|
this.options.sessionDir,
|
|
669
1003
|
this.options.cwd,
|
|
670
1004
|
);
|
|
1005
|
+
verifyChildSessionIdentity(sessionManager, agent, this.options.rootSessionId);
|
|
1006
|
+
if (agent.session_leaf_id) sessionManager.branch(agent.session_leaf_id);
|
|
671
1007
|
const coordinatorTools = this.options.getCoordinatorTools(agent.agent_id);
|
|
672
1008
|
const allowedToolNames = [
|
|
673
1009
|
...agent.launch_contract.ordinary_tools,
|