@ian-pascoe/pi-minimal-subagents 0.1.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/LICENSE +21 -0
- package/README.md +129 -0
- package/package.json +51 -0
- package/src/index.ts +1 -0
- package/src/minimal-subagents-capabilities.ts +118 -0
- package/src/minimal-subagents-config.ts +217 -0
- package/src/minimal-subagents-context.ts +70 -0
- package/src/minimal-subagents-coordinator.ts +1230 -0
- package/src/minimal-subagents-extension.ts +279 -0
- package/src/minimal-subagents-fork-lifecycle.ts +36 -0
- package/src/minimal-subagents-registry.ts +219 -0
- package/src/minimal-subagents-rendering.ts +717 -0
- package/src/minimal-subagents-sessions.ts +702 -0
- package/src/minimal-subagents-shutdown.ts +29 -0
- package/src/minimal-subagents-tool-schemas.ts +66 -0
- package/src/minimal-subagents-tools.ts +285 -0
- package/src/minimal-subagents-types.ts +305 -0
- package/src/minimal-subagents-ui.ts +326 -0
- package/src/minimal-subagents-usage.ts +24 -0
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync, realpathSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { unlink } from "node:fs/promises";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
6
|
+
import type { Model, Usage } from "@earendil-works/pi-ai";
|
|
7
|
+
import { clampThinkingLevel } from "@earendil-works/pi-ai/compat";
|
|
8
|
+
import {
|
|
9
|
+
AgentSession,
|
|
10
|
+
createAgentSession,
|
|
11
|
+
DefaultResourceLoader,
|
|
12
|
+
estimateTokens,
|
|
13
|
+
findCutPoint,
|
|
14
|
+
generateSummaryWithUsage,
|
|
15
|
+
ModelRuntime,
|
|
16
|
+
SessionManager,
|
|
17
|
+
SettingsManager,
|
|
18
|
+
sessionEntryToContextMessages,
|
|
19
|
+
type ToolDefinition,
|
|
20
|
+
} from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import {
|
|
22
|
+
buildSubagentSystemPrompt,
|
|
23
|
+
snapshotCommittedContext,
|
|
24
|
+
} from "./minimal-subagents-context.js";
|
|
25
|
+
import {
|
|
26
|
+
canAgentContractSpawn,
|
|
27
|
+
DEFAULT_MAX_SUBAGENT_DEPTH,
|
|
28
|
+
getSubagentDepth,
|
|
29
|
+
} from "./minimal-subagents-capabilities.js";
|
|
30
|
+
import { CHILD_IDENTITY_ENTRY_TYPE } from "./minimal-subagents-registry.js";
|
|
31
|
+
import { addMinimalSubagentsUsage } from "./minimal-subagents-usage.js";
|
|
32
|
+
import type {
|
|
33
|
+
AgentSessionFactory,
|
|
34
|
+
ChildAgentRuntime,
|
|
35
|
+
CoordinatorMessage,
|
|
36
|
+
PersistedAgent,
|
|
37
|
+
PersistedSessionIdentity,
|
|
38
|
+
ProjectContextMode,
|
|
39
|
+
RuntimeCreationRequest,
|
|
40
|
+
RuntimeTurnOutcome,
|
|
41
|
+
} from "./minimal-subagents-types.js";
|
|
42
|
+
|
|
43
|
+
const PI_BUILTIN_ORDINARY_TOOL_NAMES = new Set([
|
|
44
|
+
"read",
|
|
45
|
+
"grep",
|
|
46
|
+
"find",
|
|
47
|
+
"ls",
|
|
48
|
+
"bash",
|
|
49
|
+
"edit",
|
|
50
|
+
"write",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
interface PersistentIdentityOptions {
|
|
54
|
+
agent: PersistedAgent;
|
|
55
|
+
importedMessages: AgentMessage[];
|
|
56
|
+
cwd: string;
|
|
57
|
+
sessionDir: string;
|
|
58
|
+
rootSessionId: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface ChildResourceLoaderOptionsInput {
|
|
62
|
+
cwd: string;
|
|
63
|
+
agentDir: string;
|
|
64
|
+
projectContext: ProjectContextMode;
|
|
65
|
+
extensionEntrypoint: string;
|
|
66
|
+
systemPromptBlock: string;
|
|
67
|
+
ordinaryToolNames?: readonly string[];
|
|
68
|
+
settingsManager?: SettingsManager;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Configures root ownership, model scope, child resources, and coordinator tool injection for Pi sessions. */
|
|
72
|
+
export interface PiAgentSessionFactoryOptions {
|
|
73
|
+
cwd: string;
|
|
74
|
+
agentDir: string;
|
|
75
|
+
sessionDir: string;
|
|
76
|
+
rootSessionId: string;
|
|
77
|
+
extensionEntrypoint: string;
|
|
78
|
+
models: readonly Model<any>[];
|
|
79
|
+
eligibleModelIds: readonly string[];
|
|
80
|
+
modelScopeRestricted: boolean;
|
|
81
|
+
availableToolNames: readonly string[];
|
|
82
|
+
projectTrusted: boolean;
|
|
83
|
+
maxSubagentDepth?: number;
|
|
84
|
+
getCoordinatorTools: (callerId: string) => ToolDefinition[];
|
|
85
|
+
onChildSessionActivity?: () => void;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Build one child prompt using the active delegation depth rather than persisted launch state. */
|
|
89
|
+
export function buildDepthBoundSubagentPrompt(
|
|
90
|
+
agent: PersistedAgent,
|
|
91
|
+
maxSubagentDepth = DEFAULT_MAX_SUBAGENT_DEPTH,
|
|
92
|
+
): string {
|
|
93
|
+
return buildSubagentSystemPrompt(agent.agent_id, agent.parent_id, {
|
|
94
|
+
canSpawn: canAgentContractSpawn(
|
|
95
|
+
agent.agent_id,
|
|
96
|
+
agent.launch_contract.delegation,
|
|
97
|
+
maxSubagentDepth,
|
|
98
|
+
),
|
|
99
|
+
remainingDepth: Math.max(0, maxSubagentDepth - getSubagentDepth(agent.agent_id)),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function canonicalPath(path: string): string {
|
|
104
|
+
const absolutePath = resolve(path);
|
|
105
|
+
return existsSync(absolutePath) ? realpathSync(absolutePath) : absolutePath;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function appendImportedMessage(sessionManager: SessionManager, message: AgentMessage): void {
|
|
109
|
+
if (message.role === "compactionSummary") {
|
|
110
|
+
sessionManager.appendCustomMessageEntry(
|
|
111
|
+
"minimal-subagents.imported-compaction",
|
|
112
|
+
message.summary,
|
|
113
|
+
false,
|
|
114
|
+
);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (message.role === "branchSummary") {
|
|
118
|
+
sessionManager.appendCustomMessageEntry(
|
|
119
|
+
"minimal-subagents.imported-branch-summary",
|
|
120
|
+
message.summary,
|
|
121
|
+
false,
|
|
122
|
+
);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
sessionManager.appendMessage(message as Parameters<SessionManager["appendMessage"]>[0]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Create and force-flush a writable child JSONL identity before its first model response. */
|
|
129
|
+
export function createPersistentChildIdentity(
|
|
130
|
+
options: PersistentIdentityOptions,
|
|
131
|
+
): PersistedSessionIdentity {
|
|
132
|
+
let sessionManager = SessionManager.create(options.cwd, options.sessionDir);
|
|
133
|
+
sessionManager.appendCustomEntry(CHILD_IDENTITY_ENTRY_TYPE, {
|
|
134
|
+
version: 1,
|
|
135
|
+
original_root_session_id: options.rootSessionId,
|
|
136
|
+
canonical_agent_id: options.agent.agent_id,
|
|
137
|
+
direct_parent_id: options.agent.parent_id,
|
|
138
|
+
created_at: options.agent.created_at,
|
|
139
|
+
});
|
|
140
|
+
sessionManager.appendSessionInfo(`[subagent] ${options.agent.agent_id}`);
|
|
141
|
+
sessionManager.appendModelChange(
|
|
142
|
+
options.agent.launch_contract.model.slice(0, options.agent.launch_contract.model.indexOf("/")),
|
|
143
|
+
options.agent.launch_contract.model.slice(options.agent.launch_contract.model.indexOf("/") + 1),
|
|
144
|
+
);
|
|
145
|
+
sessionManager.appendThinkingLevelChange(options.agent.launch_contract.thinking_level);
|
|
146
|
+
for (const message of options.importedMessages) appendImportedMessage(sessionManager, message);
|
|
147
|
+
|
|
148
|
+
const sessionFile = sessionManager.getSessionFile();
|
|
149
|
+
if (!sessionFile)
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Minimal subagents session creation: no session file for ${options.agent.agent_id}`,
|
|
152
|
+
);
|
|
153
|
+
if (!existsSync(sessionFile)) {
|
|
154
|
+
const lines = [sessionManager.getHeader(), ...sessionManager.getEntries()]
|
|
155
|
+
.filter((entry) => entry !== null)
|
|
156
|
+
.map((entry) => JSON.stringify(entry))
|
|
157
|
+
.join("\n");
|
|
158
|
+
writeFileSync(sessionFile, `${lines}\n`, "utf8");
|
|
159
|
+
sessionManager = SessionManager.open(sessionFile, options.sessionDir, options.cwd);
|
|
160
|
+
}
|
|
161
|
+
return { sessionFile, sessionId: sessionManager.getSessionId() };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Build child resources while filtering recursive coordinator loading and honoring project-context omission. */
|
|
165
|
+
export function createChildResourceLoaderOptions(
|
|
166
|
+
input: ChildResourceLoaderOptionsInput,
|
|
167
|
+
): ConstructorParameters<typeof DefaultResourceLoader>[0] {
|
|
168
|
+
const extensionEntrypoint = canonicalPath(input.extensionEntrypoint);
|
|
169
|
+
const omitProjectContext = input.projectContext === "omit";
|
|
170
|
+
const ordinaryToolNames = new Set(input.ordinaryToolNames ?? []);
|
|
171
|
+
const loadOrdinaryToolExtensions = [...ordinaryToolNames].some(
|
|
172
|
+
(toolName) => !PI_BUILTIN_ORDINARY_TOOL_NAMES.has(toolName),
|
|
173
|
+
);
|
|
174
|
+
return {
|
|
175
|
+
cwd: input.cwd,
|
|
176
|
+
agentDir: input.agentDir,
|
|
177
|
+
settingsManager: input.settingsManager,
|
|
178
|
+
noExtensions: !loadOrdinaryToolExtensions,
|
|
179
|
+
noContextFiles: omitProjectContext,
|
|
180
|
+
noSkills: omitProjectContext,
|
|
181
|
+
noPromptTemplates: omitProjectContext,
|
|
182
|
+
extensionsOverride: loadOrdinaryToolExtensions
|
|
183
|
+
? (base) => ({
|
|
184
|
+
...base,
|
|
185
|
+
extensions: base.extensions.filter(
|
|
186
|
+
(extension) =>
|
|
187
|
+
canonicalPath(extension.resolvedPath) !== extensionEntrypoint &&
|
|
188
|
+
[...extension.tools.keys()].some((toolName) => ordinaryToolNames.has(toolName)),
|
|
189
|
+
),
|
|
190
|
+
errors: base.errors.filter((error) => canonicalPath(error.path) !== extensionEntrypoint),
|
|
191
|
+
})
|
|
192
|
+
: undefined,
|
|
193
|
+
agentsFilesOverride: omitProjectContext ? () => ({ agentsFiles: [] }) : undefined,
|
|
194
|
+
skillsOverride: omitProjectContext
|
|
195
|
+
? (base) => ({ skills: [], diagnostics: base.diagnostics })
|
|
196
|
+
: undefined,
|
|
197
|
+
promptsOverride: omitProjectContext
|
|
198
|
+
? (base) => ({ prompts: [], diagnostics: base.diagnostics })
|
|
199
|
+
: undefined,
|
|
200
|
+
systemPromptOverride: omitProjectContext ? () => undefined : undefined,
|
|
201
|
+
appendSystemPromptOverride: (base) =>
|
|
202
|
+
omitProjectContext ? [input.systemPromptBlock] : [...base, input.systemPromptBlock],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Find durable keyed evidence for exactly-once wait or custom-result delivery. */
|
|
207
|
+
export function findDeliveryEvidence(
|
|
208
|
+
entries: readonly unknown[],
|
|
209
|
+
sourceAgentId: string,
|
|
210
|
+
sourceTurnId: string,
|
|
211
|
+
): boolean {
|
|
212
|
+
return entries.some((entry) => {
|
|
213
|
+
if (!entry || typeof entry !== "object") return false;
|
|
214
|
+
const candidate = entry as {
|
|
215
|
+
type?: string;
|
|
216
|
+
customType?: string;
|
|
217
|
+
details?: unknown;
|
|
218
|
+
message?: { role?: string; toolName?: string; details?: unknown };
|
|
219
|
+
};
|
|
220
|
+
const details =
|
|
221
|
+
candidate.type === "custom_message" && candidate.customType === "minimal-subagents.result"
|
|
222
|
+
? candidate.details
|
|
223
|
+
: candidate.type === "message" &&
|
|
224
|
+
candidate.message?.role === "toolResult" &&
|
|
225
|
+
candidate.message.toolName === "subagent_wait"
|
|
226
|
+
? candidate.message.details
|
|
227
|
+
: undefined;
|
|
228
|
+
if (!details || typeof details !== "object") return false;
|
|
229
|
+
const key = details as { source_agent_id?: string; source_turn_id?: string };
|
|
230
|
+
return key.source_agent_id === sourceAgentId && key.source_turn_id === sourceTurnId;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function sumUsage(messages: readonly AgentMessage[]): Usage | undefined {
|
|
235
|
+
return messages.reduce<Usage | undefined>(
|
|
236
|
+
(total, message) =>
|
|
237
|
+
addMinimalSubagentsUsage(total, "usage" in message ? message.usage : undefined),
|
|
238
|
+
undefined,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function assistantText(message: AgentMessage | undefined): string {
|
|
243
|
+
if (!message || message.role !== "assistant") return "";
|
|
244
|
+
return message.content
|
|
245
|
+
.filter((content) => content.type === "text")
|
|
246
|
+
.map((content) => content.text)
|
|
247
|
+
.join("\n");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
class PiChildAgentRuntime implements ChildAgentRuntime {
|
|
251
|
+
private aborted = false;
|
|
252
|
+
private readonly unsubscribe: () => void;
|
|
253
|
+
|
|
254
|
+
constructor(
|
|
255
|
+
private readonly session: AgentSession,
|
|
256
|
+
private readonly modelRuntime: ModelRuntime,
|
|
257
|
+
private readonly modelById: ReadonlyMap<string, Model<any>>,
|
|
258
|
+
onSessionActivity?: () => void,
|
|
259
|
+
) {
|
|
260
|
+
this.unsubscribe = session.subscribe((event) => {
|
|
261
|
+
if (event.type !== "entry_appended") return;
|
|
262
|
+
if (
|
|
263
|
+
event.entry.type === "custom_message" ||
|
|
264
|
+
(event.entry.type === "message" && event.entry.message.role === "toolResult")
|
|
265
|
+
) {
|
|
266
|
+
onSessionActivity?.();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
get sessionFile(): string {
|
|
272
|
+
const sessionFile = this.session.sessionFile;
|
|
273
|
+
if (!sessionFile)
|
|
274
|
+
throw new Error("Minimal subagents child runtime lost its persistent session file");
|
|
275
|
+
return sessionFile;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
get sessionId(): string {
|
|
279
|
+
return this.session.sessionId;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
get isRunning(): boolean {
|
|
283
|
+
return this.session.isStreaming;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async runPrompt(
|
|
287
|
+
task: string,
|
|
288
|
+
compact: boolean,
|
|
289
|
+
callerModel: string,
|
|
290
|
+
callerThinkingLevel: ThinkingLevel,
|
|
291
|
+
): Promise<RuntimeTurnOutcome> {
|
|
292
|
+
if (compact) await this.compactImportedContext(callerModel, callerThinkingLevel);
|
|
293
|
+
return this.captureTurn(() => this.session.prompt(task, { expandPromptTemplates: false }));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
runMessage(message: CoordinatorMessage): Promise<RuntimeTurnOutcome> {
|
|
297
|
+
return this.captureTurn(() =>
|
|
298
|
+
this.session.sendCustomMessage(
|
|
299
|
+
{
|
|
300
|
+
customType: message.customType,
|
|
301
|
+
content: message.content,
|
|
302
|
+
display: true,
|
|
303
|
+
details: message.details,
|
|
304
|
+
},
|
|
305
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async steerCoordinatorMessage(message: CoordinatorMessage): Promise<void> {
|
|
311
|
+
await this.session.sendCustomMessage(
|
|
312
|
+
{
|
|
313
|
+
customType: message.customType,
|
|
314
|
+
content: message.content,
|
|
315
|
+
display: true,
|
|
316
|
+
details: message.details,
|
|
317
|
+
},
|
|
318
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async abort(): Promise<void> {
|
|
323
|
+
this.aborted = true;
|
|
324
|
+
await this.session.abort();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
dispose(): void {
|
|
328
|
+
this.unsubscribe();
|
|
329
|
+
this.session.dispose();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
snapshotCommittedMessages(): AgentMessage[] {
|
|
333
|
+
return snapshotCommittedContext(this.session.messages, this.session.isStreaming);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string): boolean {
|
|
337
|
+
return findDeliveryEvidence(
|
|
338
|
+
this.session.sessionManager.getEntries(),
|
|
339
|
+
sourceAgentId,
|
|
340
|
+
sourceTurnId,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
getUsage(): Usage | undefined {
|
|
345
|
+
return sumUsage(this.session.messages);
|
|
346
|
+
}
|
|
347
|
+
|
|
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
|
+
private async captureTurn(operation: () => Promise<void>): Promise<RuntimeTurnOutcome> {
|
|
359
|
+
const messageStart = this.session.messages.length;
|
|
360
|
+
this.aborted = false;
|
|
361
|
+
try {
|
|
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
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private async compactImportedContext(
|
|
404
|
+
callerModelId: string,
|
|
405
|
+
thinkingLevel: ThinkingLevel,
|
|
406
|
+
): Promise<void> {
|
|
407
|
+
const contextEntries = this.session.sessionManager.buildContextEntries();
|
|
408
|
+
const cutPoint = findCutPoint(
|
|
409
|
+
contextEntries,
|
|
410
|
+
0,
|
|
411
|
+
contextEntries.length,
|
|
412
|
+
this.session.settingsManager.getCompactionKeepRecentTokens(),
|
|
413
|
+
);
|
|
414
|
+
const firstKeptEntryIndex =
|
|
415
|
+
cutPoint.isSplitTurn && cutPoint.turnStartIndex >= 0
|
|
416
|
+
? cutPoint.turnStartIndex
|
|
417
|
+
: cutPoint.firstKeptEntryIndex;
|
|
418
|
+
const firstKeptEntry = contextEntries[firstKeptEntryIndex];
|
|
419
|
+
if (!firstKeptEntry || firstKeptEntryIndex <= 0) return;
|
|
420
|
+
const messagesToSummarize = contextEntries
|
|
421
|
+
.slice(0, firstKeptEntryIndex)
|
|
422
|
+
.flatMap((entry) => sessionEntryToContextMessages(entry));
|
|
423
|
+
if (messagesToSummarize.length === 0) return;
|
|
424
|
+
const model = this.modelById.get(callerModelId);
|
|
425
|
+
if (!model)
|
|
426
|
+
throw new Error(
|
|
427
|
+
`Minimal subagents compact context: unavailable caller model ${callerModelId}`,
|
|
428
|
+
);
|
|
429
|
+
const auth = await this.modelRuntime.getAuth(model);
|
|
430
|
+
if (!auth)
|
|
431
|
+
throw new Error(
|
|
432
|
+
`Minimal subagents compact context: authentication unavailable for ${callerModelId}`,
|
|
433
|
+
);
|
|
434
|
+
const summary = await generateSummaryWithUsage(
|
|
435
|
+
messagesToSummarize,
|
|
436
|
+
model,
|
|
437
|
+
this.session.settingsManager.getCompactionReserveTokens(),
|
|
438
|
+
auth.auth.apiKey,
|
|
439
|
+
auth.auth.headers
|
|
440
|
+
? Object.fromEntries(
|
|
441
|
+
Object.entries(auth.auth.headers).filter(
|
|
442
|
+
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
443
|
+
),
|
|
444
|
+
)
|
|
445
|
+
: undefined,
|
|
446
|
+
undefined,
|
|
447
|
+
undefined,
|
|
448
|
+
undefined,
|
|
449
|
+
thinkingLevel,
|
|
450
|
+
(streamModel, context, options) =>
|
|
451
|
+
this.modelRuntime.streamSimple(streamModel, context, options),
|
|
452
|
+
auth.env,
|
|
453
|
+
{ enabled: false, maxRetries: 0, baseDelayMs: 0 },
|
|
454
|
+
);
|
|
455
|
+
const tokensBefore = contextEntries
|
|
456
|
+
.flatMap((entry) => sessionEntryToContextMessages(entry))
|
|
457
|
+
.reduce((total, message) => total + estimateTokens(message), 0);
|
|
458
|
+
this.session.sessionManager.appendCompaction(
|
|
459
|
+
summary.text,
|
|
460
|
+
firstKeptEntry.id,
|
|
461
|
+
tokensBefore,
|
|
462
|
+
{ source: "minimal-subagents", caller_model: callerModelId },
|
|
463
|
+
false,
|
|
464
|
+
summary.usage,
|
|
465
|
+
);
|
|
466
|
+
this.session.agent.state.messages = this.session.sessionManager.buildSessionContext().messages;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Production Pi SDK session factory used by the process-local coordinator. */
|
|
471
|
+
export class PiAgentSessionFactory implements AgentSessionFactory {
|
|
472
|
+
private readonly modelById: Map<string, Model<any>>;
|
|
473
|
+
private readonly eligibleModelIds: Set<string>;
|
|
474
|
+
private readonly availableToolNames: Set<string>;
|
|
475
|
+
private readonly discoveredToolNames = new Map<string, Promise<Set<string>>>();
|
|
476
|
+
|
|
477
|
+
constructor(private readonly options: PiAgentSessionFactoryOptions) {
|
|
478
|
+
this.modelById = new Map(
|
|
479
|
+
options.models.map((model) => [`${model.provider}/${model.id}`, model]),
|
|
480
|
+
);
|
|
481
|
+
this.eligibleModelIds = new Set(options.eligibleModelIds);
|
|
482
|
+
this.availableToolNames = new Set(options.availableToolNames);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
createIdentity(
|
|
486
|
+
agent: PersistedAgent,
|
|
487
|
+
importedMessages: AgentMessage[],
|
|
488
|
+
): PersistedSessionIdentity {
|
|
489
|
+
return createPersistentChildIdentity({
|
|
490
|
+
agent,
|
|
491
|
+
importedMessages,
|
|
492
|
+
cwd: this.options.cwd,
|
|
493
|
+
sessionDir: this.options.sessionDir,
|
|
494
|
+
rootSessionId: this.options.rootSessionId,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
createRuntime(request: RuntimeCreationRequest): Promise<ChildAgentRuntime> {
|
|
499
|
+
return this.openRuntime(request.agent);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
restoreRuntime(agent: PersistedAgent): Promise<ChildAgentRuntime> {
|
|
503
|
+
return this.openRuntime(agent);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
resolveLaunchMissingDependencies(agent: PersistedAgent): Promise<string[]> {
|
|
507
|
+
return this.findMissingDependencies(agent, false);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
resolveRestorationMissingDependencies(agent: PersistedAgent): Promise<string[]> {
|
|
511
|
+
return this.findMissingDependencies(agent, this.options.modelScopeRestricted);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
resolveThinkingLevel(modelId: string, requested: ThinkingLevel): ThinkingLevel {
|
|
515
|
+
const model = this.modelById.get(modelId);
|
|
516
|
+
if (!model) return requested;
|
|
517
|
+
return clampThinkingLevel(model, requested);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
modelSupportsImages(modelId: string): boolean {
|
|
521
|
+
return this.modelById.get(modelId)?.input.includes("image") ?? false;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async cloneSession(agent: PersistedAgent): Promise<PersistedSessionIdentity> {
|
|
525
|
+
if (!agent.session_file) {
|
|
526
|
+
throw new Error(`Minimal subagents fork clone: ${agent.agent_id} has no source session`);
|
|
527
|
+
}
|
|
528
|
+
const source = SessionManager.open(
|
|
529
|
+
agent.session_file,
|
|
530
|
+
this.options.sessionDir,
|
|
531
|
+
this.options.cwd,
|
|
532
|
+
);
|
|
533
|
+
const leafId = source.getLeafId();
|
|
534
|
+
if (!leafId)
|
|
535
|
+
throw new Error(`Minimal subagents fork clone: ${agent.agent_id} has no child leaf`);
|
|
536
|
+
const sessionFile = source.createBranchedSession(leafId);
|
|
537
|
+
if (!sessionFile)
|
|
538
|
+
throw new Error(`Minimal subagents fork clone: ${agent.agent_id} is not persistent`);
|
|
539
|
+
source.appendCustomEntry("minimal-subagents.fork-clone", {
|
|
540
|
+
source_agent_id: agent.agent_id,
|
|
541
|
+
source_session_id: agent.session_id,
|
|
542
|
+
});
|
|
543
|
+
if (!existsSync(sessionFile)) {
|
|
544
|
+
const lines = [source.getHeader(), ...source.getEntries()]
|
|
545
|
+
.filter((entry) => entry !== null)
|
|
546
|
+
.map((entry) => JSON.stringify(entry))
|
|
547
|
+
.join("\n");
|
|
548
|
+
writeFileSync(sessionFile, `${lines}\n`, "utf8");
|
|
549
|
+
}
|
|
550
|
+
if (!existsSync(sessionFile)) {
|
|
551
|
+
throw new Error(`Minimal subagents fork clone: clone was not flushed for ${agent.agent_id}`);
|
|
552
|
+
}
|
|
553
|
+
return { sessionFile, sessionId: source.getSessionId() };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async trashSessionFile(sessionFile: string): Promise<void> {
|
|
557
|
+
const trashError = await new Promise<Error | undefined>((resolvePromise) => {
|
|
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
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private buildChildSystemPrompt(agent: PersistedAgent): string {
|
|
574
|
+
return buildDepthBoundSubagentPrompt(
|
|
575
|
+
agent,
|
|
576
|
+
this.options.maxSubagentDepth ?? DEFAULT_MAX_SUBAGENT_DEPTH,
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
private async findMissingDependencies(
|
|
581
|
+
agent: PersistedAgent,
|
|
582
|
+
requireEligibleModel: boolean,
|
|
583
|
+
): Promise<string[]> {
|
|
584
|
+
const missing: string[] = [];
|
|
585
|
+
if (!this.modelById.has(agent.launch_contract.model)) missing.push(agent.launch_contract.model);
|
|
586
|
+
else if (requireEligibleModel && !this.eligibleModelIds.has(agent.launch_contract.model)) {
|
|
587
|
+
missing.push(agent.launch_contract.model);
|
|
588
|
+
}
|
|
589
|
+
const discoveredTools = await this.discoverChildToolNames(agent);
|
|
590
|
+
for (const toolName of agent.launch_contract.ordinary_tools) {
|
|
591
|
+
if (!discoveredTools.has(toolName)) missing.push(toolName);
|
|
592
|
+
}
|
|
593
|
+
return [...new Set(missing)];
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private discoverChildToolNames(agent: PersistedAgent): Promise<Set<string>> {
|
|
597
|
+
const cacheKey = `${agent.launch_contract.project_context}:${[
|
|
598
|
+
...agent.launch_contract.ordinary_tools,
|
|
599
|
+
]
|
|
600
|
+
.sort()
|
|
601
|
+
.join(",")}`;
|
|
602
|
+
const cached = this.discoveredToolNames.get(cacheKey);
|
|
603
|
+
if (cached) return cached;
|
|
604
|
+
const discovery = (async () => {
|
|
605
|
+
const names = new Set(
|
|
606
|
+
[...PI_BUILTIN_ORDINARY_TOOL_NAMES].filter((name) => this.availableToolNames.has(name)),
|
|
607
|
+
);
|
|
608
|
+
const requiresCustomToolDiscovery = agent.launch_contract.ordinary_tools.some(
|
|
609
|
+
(name) => !PI_BUILTIN_ORDINARY_TOOL_NAMES.has(name),
|
|
610
|
+
);
|
|
611
|
+
if (!requiresCustomToolDiscovery) return names;
|
|
612
|
+
const settingsManager = SettingsManager.create(this.options.cwd, this.options.agentDir, {
|
|
613
|
+
projectTrusted: this.options.projectTrusted,
|
|
614
|
+
});
|
|
615
|
+
const resourceLoader = new DefaultResourceLoader(
|
|
616
|
+
createChildResourceLoaderOptions({
|
|
617
|
+
cwd: this.options.cwd,
|
|
618
|
+
agentDir: this.options.agentDir,
|
|
619
|
+
projectContext: agent.launch_contract.project_context,
|
|
620
|
+
extensionEntrypoint: this.options.extensionEntrypoint,
|
|
621
|
+
systemPromptBlock: this.buildChildSystemPrompt(agent),
|
|
622
|
+
ordinaryToolNames: agent.launch_contract.ordinary_tools,
|
|
623
|
+
settingsManager,
|
|
624
|
+
}),
|
|
625
|
+
);
|
|
626
|
+
await resourceLoader.reload();
|
|
627
|
+
for (const extension of resourceLoader.getExtensions().extensions) {
|
|
628
|
+
for (const toolName of extension.tools.keys()) names.add(toolName);
|
|
629
|
+
}
|
|
630
|
+
return names;
|
|
631
|
+
})();
|
|
632
|
+
this.discoveredToolNames.set(cacheKey, discovery);
|
|
633
|
+
return discovery;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
private async openRuntime(agent: PersistedAgent): Promise<ChildAgentRuntime> {
|
|
637
|
+
if (!agent.session_file)
|
|
638
|
+
throw new Error(`Minimal subagents restore: ${agent.agent_id} has no session file`);
|
|
639
|
+
const model = this.modelById.get(agent.launch_contract.model);
|
|
640
|
+
if (!model)
|
|
641
|
+
throw new Error(
|
|
642
|
+
`Minimal subagents restore: model unavailable: ${agent.launch_contract.model}`,
|
|
643
|
+
);
|
|
644
|
+
const settingsManager = SettingsManager.create(this.options.cwd, this.options.agentDir, {
|
|
645
|
+
projectTrusted: this.options.projectTrusted,
|
|
646
|
+
});
|
|
647
|
+
settingsManager.applyOverrides({
|
|
648
|
+
retry: { enabled: false, maxRetries: 0, provider: { maxRetries: 0 } },
|
|
649
|
+
});
|
|
650
|
+
const resourceLoader = new DefaultResourceLoader(
|
|
651
|
+
createChildResourceLoaderOptions({
|
|
652
|
+
cwd: this.options.cwd,
|
|
653
|
+
agentDir: this.options.agentDir,
|
|
654
|
+
projectContext: agent.launch_contract.project_context,
|
|
655
|
+
extensionEntrypoint: this.options.extensionEntrypoint,
|
|
656
|
+
systemPromptBlock: this.buildChildSystemPrompt(agent),
|
|
657
|
+
ordinaryToolNames: agent.launch_contract.ordinary_tools,
|
|
658
|
+
settingsManager,
|
|
659
|
+
}),
|
|
660
|
+
);
|
|
661
|
+
await resourceLoader.reload();
|
|
662
|
+
const modelRuntime = await ModelRuntime.create({
|
|
663
|
+
authPath: resolve(this.options.agentDir, "auth.json"),
|
|
664
|
+
modelsPath: resolve(this.options.agentDir, "models.json"),
|
|
665
|
+
});
|
|
666
|
+
const sessionManager = SessionManager.open(
|
|
667
|
+
agent.session_file,
|
|
668
|
+
this.options.sessionDir,
|
|
669
|
+
this.options.cwd,
|
|
670
|
+
);
|
|
671
|
+
const coordinatorTools = this.options.getCoordinatorTools(agent.agent_id);
|
|
672
|
+
const allowedToolNames = [
|
|
673
|
+
...agent.launch_contract.ordinary_tools,
|
|
674
|
+
...coordinatorTools.map((tool) => tool.name),
|
|
675
|
+
];
|
|
676
|
+
const { session } = await createAgentSession({
|
|
677
|
+
cwd: this.options.cwd,
|
|
678
|
+
agentDir: this.options.agentDir,
|
|
679
|
+
model,
|
|
680
|
+
thinkingLevel: agent.launch_contract.thinking_level,
|
|
681
|
+
tools: allowedToolNames,
|
|
682
|
+
customTools: coordinatorTools,
|
|
683
|
+
resourceLoader,
|
|
684
|
+
sessionManager,
|
|
685
|
+
settingsManager,
|
|
686
|
+
modelRuntime,
|
|
687
|
+
});
|
|
688
|
+
await session.bindExtensions({ mode: "print" });
|
|
689
|
+
const activeNames = new Set(session.getActiveToolNames());
|
|
690
|
+
const missingTools = allowedToolNames.filter((toolName) => !activeNames.has(toolName));
|
|
691
|
+
if (missingTools.length > 0) {
|
|
692
|
+
session.dispose();
|
|
693
|
+
throw new Error(`Minimal subagents child tool loading failed: ${missingTools.join(", ")}`);
|
|
694
|
+
}
|
|
695
|
+
return new PiChildAgentRuntime(
|
|
696
|
+
session,
|
|
697
|
+
modelRuntime,
|
|
698
|
+
this.modelById,
|
|
699
|
+
this.options.onChildSessionActivity,
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
}
|