@harms-haus/pi-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 +362 -0
- package/docs/architecture.md +554 -0
- package/docs/changelog.md +61 -0
- package/docs/profiles.md +546 -0
- package/docs/settings.md +52 -0
- package/docs/tools-reference.md +519 -0
- package/package.json +59 -0
- package/src/cache.ts +24 -0
- package/src/commands/profile.ts +176 -0
- package/src/format-tool-call.ts +597 -0
- package/src/format-transcript.ts +151 -0
- package/src/index.ts +117 -0
- package/src/profile-editor.ts +356 -0
- package/src/profile-formatting.ts +178 -0
- package/src/profile-types.ts +73 -0
- package/src/profiles.ts +577 -0
- package/src/schemas.ts +65 -0
- package/src/settings.ts +155 -0
- package/src/skill-discovery.ts +30 -0
- package/src/spawner.ts +523 -0
- package/src/tools/delegate-render.ts +285 -0
- package/src/tools/delegate.ts +867 -0
- package/src/tools/retrieval.ts +287 -0
- package/src/types.ts +232 -0
- package/src/utils.ts +168 -0
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delegate to Sub-agents Tool
|
|
3
|
+
*
|
|
4
|
+
* Tool registration for spawning parallel sub-agents to work on separate tasks.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { readFile, stat } from "node:fs/promises";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join, resolve, sep } from "node:path";
|
|
11
|
+
import { loadSkills as discoverSkills } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import {
|
|
13
|
+
applyExcludeTools,
|
|
14
|
+
loadProfiles,
|
|
15
|
+
profileSummary,
|
|
16
|
+
resolveProfile,
|
|
17
|
+
resolveProfileSkills,
|
|
18
|
+
validateProfileTools,
|
|
19
|
+
validateProfileSkills,
|
|
20
|
+
} from "../profiles";
|
|
21
|
+
import { DelegateParams } from "../schemas";
|
|
22
|
+
import {
|
|
23
|
+
loadExtendTimeoutDebounce,
|
|
24
|
+
loadLoopingToolCount,
|
|
25
|
+
loadMaxLinesPerWindow,
|
|
26
|
+
} from "../settings";
|
|
27
|
+
import { resolvePackageSkillPaths } from "../skill-discovery";
|
|
28
|
+
import { runSubAgent } from "../spawner";
|
|
29
|
+
import {
|
|
30
|
+
CUSTOM_ENTRY_TYPE,
|
|
31
|
+
DEFAULT_TIMEOUT,
|
|
32
|
+
formatRunsForResume,
|
|
33
|
+
LOOP_DETECTED_MESSAGE,
|
|
34
|
+
MAX_CONCURRENCY,
|
|
35
|
+
serializeSessionData,
|
|
36
|
+
} from "../types";
|
|
37
|
+
import type { SubAgentTask } from "../types";
|
|
38
|
+
import { getSummaryText, mapWithConcurrencyLimit } from "../utils";
|
|
39
|
+
import { renderDelegateCall, renderDelegateResult } from "./delegate-render";
|
|
40
|
+
import type { SubagentProfile } from "../profile-types";
|
|
41
|
+
import type {
|
|
42
|
+
FileSpec,
|
|
43
|
+
SessionRecord,
|
|
44
|
+
SubAgentWindow,
|
|
45
|
+
SubagentSessionData,
|
|
46
|
+
WindowedSubagentDetails,
|
|
47
|
+
} from "../types";
|
|
48
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
49
|
+
|
|
50
|
+
// ── File Reading ──────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/** Inferred type from the DelegateParams schema */
|
|
53
|
+
type StaticDelegateParams = {
|
|
54
|
+
tasks: SubAgentTask[];
|
|
55
|
+
profile?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Resolved profile data for a single task */
|
|
59
|
+
type ResolvedProfileEntry = { name?: string; profile?: SubagentProfile };
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Apply line slicing to file contents based on a FileSpec.
|
|
63
|
+
* Returns the sliced lines as-is if the spec is a plain string path.
|
|
64
|
+
*/
|
|
65
|
+
function sliceLines(lines: string[], spec: FileSpec): string[] {
|
|
66
|
+
if (typeof spec === "string") return lines;
|
|
67
|
+
if ("tail" in spec) return spec.tail > 0 ? lines.slice(-spec.tail) : [];
|
|
68
|
+
if ("head" in spec) return spec.head > 0 ? lines.slice(0, spec.head) : [];
|
|
69
|
+
return lines.slice((spec.start ?? 1) - 1, spec.end ?? lines.length);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read a file and return its formatted contents for prompt injection.
|
|
74
|
+
* Returns `[file not found: <path>]` if the file doesn't exist or can't be read.
|
|
75
|
+
* Line numbers are 1-indexed and inclusive.
|
|
76
|
+
*/
|
|
77
|
+
async function readFileContents(spec: FileSpec, cwd: string): Promise<string> {
|
|
78
|
+
const path = typeof spec === "string" ? spec : spec.path;
|
|
79
|
+
const absolutePath = resolve(cwd, path);
|
|
80
|
+
|
|
81
|
+
// Prevent path traversal outside cwd
|
|
82
|
+
const resolvedCwd = resolve(cwd);
|
|
83
|
+
if (absolutePath !== resolvedCwd && !absolutePath.startsWith(resolvedCwd + sep)) {
|
|
84
|
+
return `[access denied: path outside project directory: ${path}]`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Check file size before reading
|
|
88
|
+
const MAX_FILE_BYTES = 1 * 1024 * 1024; // 1 MB
|
|
89
|
+
let fileStat;
|
|
90
|
+
try {
|
|
91
|
+
fileStat = await stat(absolutePath);
|
|
92
|
+
if (fileStat.size > MAX_FILE_BYTES) {
|
|
93
|
+
return `[file too large: ${path} (${Math.round(fileStat.size / 1024)}KB, limit ${MAX_FILE_BYTES / 1024}KB)]`;
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
return `[file not found: ${path}]`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let contents: string;
|
|
100
|
+
try {
|
|
101
|
+
contents = await readFile(absolutePath, "utf-8");
|
|
102
|
+
} catch {
|
|
103
|
+
return `[could not read file: ${path}]`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let lines = contents.split("\n");
|
|
107
|
+
|
|
108
|
+
// Strip trailing empty line from newline-terminated files (before slicing)
|
|
109
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
110
|
+
lines.pop();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
lines = sliceLines(lines, spec);
|
|
114
|
+
|
|
115
|
+
return `=== ${path} ===\n${lines.join("\n")}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Profile Resolution ────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Apply tool exclude lists to resolved profiles.
|
|
122
|
+
* Returns the list of all tool names (lazily computed).
|
|
123
|
+
*/
|
|
124
|
+
function applyToolExcludeLists(
|
|
125
|
+
resolvedProfiles: ResolvedProfileEntry[],
|
|
126
|
+
pi: ExtensionAPI,
|
|
127
|
+
): string[] | undefined {
|
|
128
|
+
let allToolNames: string[] | undefined;
|
|
129
|
+
for (let i = 0; i < resolvedProfiles.length; i++) {
|
|
130
|
+
const entry = resolvedProfiles[i];
|
|
131
|
+
if (!entry) continue;
|
|
132
|
+
const { name, profile } = entry;
|
|
133
|
+
if (profile?.excludeTools && profile.excludeTools.length > 0) {
|
|
134
|
+
if (!allToolNames) {
|
|
135
|
+
allToolNames = pi.getAllTools().map((t) => t.name);
|
|
136
|
+
}
|
|
137
|
+
validateProfileTools(profile, name);
|
|
138
|
+
resolvedProfiles[i] = { name, profile: applyExcludeTools(profile, allToolNames) };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return allToolNames;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Discover skills if any profile needs skill resolution.
|
|
146
|
+
* Returns a skill map keyed by skill name, or undefined if no resolution needed.
|
|
147
|
+
*/
|
|
148
|
+
async function discoverSkillsIfNeeded(
|
|
149
|
+
resolvedProfiles: ResolvedProfileEntry[],
|
|
150
|
+
cwd: string,
|
|
151
|
+
agentDir: string,
|
|
152
|
+
): Promise<Map<string, { filePath: string; name: string; description: string }> | undefined> {
|
|
153
|
+
const needsSkillResolution = resolvedProfiles.some(
|
|
154
|
+
({ profile }) => profile && (profile.suggestedSkills?.length || profile.loadSkills?.length),
|
|
155
|
+
);
|
|
156
|
+
if (!needsSkillResolution) return undefined;
|
|
157
|
+
|
|
158
|
+
const packageSkillPaths = await resolvePackageSkillPaths(cwd, agentDir);
|
|
159
|
+
const discResult = discoverSkills({
|
|
160
|
+
cwd,
|
|
161
|
+
agentDir,
|
|
162
|
+
skillPaths: packageSkillPaths,
|
|
163
|
+
includeDefaults: true,
|
|
164
|
+
});
|
|
165
|
+
return new Map(discResult.skills.map((s) => [s.name, s]));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Pre-resolve skills for each unique profile to avoid repeated file reads.
|
|
170
|
+
*/
|
|
171
|
+
async function preResolveProfileSkills(
|
|
172
|
+
resolvedProfiles: ResolvedProfileEntry[],
|
|
173
|
+
cwd: string,
|
|
174
|
+
skillMap: Map<string, { filePath: string; name: string; description: string }> | undefined,
|
|
175
|
+
): Promise<
|
|
176
|
+
Map<SubagentProfile, { ok: true; profile: SubagentProfile } | { ok: false; error: string }>
|
|
177
|
+
> {
|
|
178
|
+
const skillResolvedProfiles = new Map<
|
|
179
|
+
SubagentProfile,
|
|
180
|
+
{ ok: true; profile: SubagentProfile } | { ok: false; error: string }
|
|
181
|
+
>();
|
|
182
|
+
for (const { profile } of resolvedProfiles) {
|
|
183
|
+
if (
|
|
184
|
+
profile &&
|
|
185
|
+
!skillResolvedProfiles.has(profile) &&
|
|
186
|
+
(profile.suggestedSkills?.length || profile.loadSkills?.length)
|
|
187
|
+
) {
|
|
188
|
+
try {
|
|
189
|
+
skillResolvedProfiles.set(profile, {
|
|
190
|
+
ok: true,
|
|
191
|
+
profile: await resolveProfileSkills(profile, cwd, skillMap),
|
|
192
|
+
});
|
|
193
|
+
} catch (skillError) {
|
|
194
|
+
skillResolvedProfiles.set(profile, {
|
|
195
|
+
ok: false,
|
|
196
|
+
error: skillError instanceof Error ? skillError.message : String(skillError),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return skillResolvedProfiles;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Result of resolving all task profiles, skills, and tool allowlists */
|
|
205
|
+
interface ProfileResolutionResult {
|
|
206
|
+
profiles: Record<string, SubagentProfile>;
|
|
207
|
+
resolvedProfiles: ResolvedProfileEntry[];
|
|
208
|
+
skillResolvedProfiles: Map<
|
|
209
|
+
SubagentProfile,
|
|
210
|
+
{ ok: true; profile: SubagentProfile } | { ok: false; error: string }
|
|
211
|
+
>;
|
|
212
|
+
allToolNames: string[] | undefined;
|
|
213
|
+
agentDir: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Resolve profiles, validate tools/skills, and pre-resolve skill paths
|
|
218
|
+
* for all tasks in a delegation request.
|
|
219
|
+
*/
|
|
220
|
+
async function resolveTaskProfiles(
|
|
221
|
+
params: StaticDelegateParams,
|
|
222
|
+
cwd: string,
|
|
223
|
+
pi: ExtensionAPI,
|
|
224
|
+
): Promise<ProfileResolutionResult> {
|
|
225
|
+
const profiles = loadProfiles(cwd);
|
|
226
|
+
|
|
227
|
+
// Pre-resolve profiles for each task (avoids double resolution)
|
|
228
|
+
const resolvedProfiles: ResolvedProfileEntry[] = params.tasks.map((t) => {
|
|
229
|
+
const name = t.profile ?? params.profile;
|
|
230
|
+
const profile = name ? resolveProfile(profiles, name) : undefined;
|
|
231
|
+
return { name, profile };
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const allToolNames = applyToolExcludeLists(resolvedProfiles, pi);
|
|
235
|
+
|
|
236
|
+
// Validate skills in profiles
|
|
237
|
+
for (const { name, profile } of resolvedProfiles) {
|
|
238
|
+
if (profile) {
|
|
239
|
+
validateProfileSkills(profile, name);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const agentDir = process.env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
244
|
+
const skillMap = await discoverSkillsIfNeeded(resolvedProfiles, cwd, agentDir);
|
|
245
|
+
const skillResolvedProfiles = await preResolveProfileSkills(resolvedProfiles, cwd, skillMap);
|
|
246
|
+
|
|
247
|
+
return { profiles, resolvedProfiles, skillResolvedProfiles, allToolNames, agentDir };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── Resume Validation ─────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Validate resume parameters for all tasks.
|
|
254
|
+
* Throws if any task references a non-existent or still-running session.
|
|
255
|
+
*/
|
|
256
|
+
function validateResumeParams(
|
|
257
|
+
tasks: StaticDelegateParams["tasks"],
|
|
258
|
+
sessionStore: Map<string, SessionRecord>,
|
|
259
|
+
getActiveSessionIds: () => Set<string>,
|
|
260
|
+
): void {
|
|
261
|
+
const activeIds = getActiveSessionIds();
|
|
262
|
+
for (const task of tasks) {
|
|
263
|
+
if (!task.resume) continue;
|
|
264
|
+
const record = sessionStore.get(task.resume);
|
|
265
|
+
if (!record || record.runs.length === 0) {
|
|
266
|
+
throw new Error(
|
|
267
|
+
`Cannot resume: session "${task.resume}" not found. The session may have expired or the ID is incorrect.`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (activeIds.has(task.resume)) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
`Cannot resume: session "${task.resume}" is still running. Wait for it to complete before resuming.`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── Window & Session Creation ─────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Compute profile display info string, or undefined if no profile.
|
|
282
|
+
*/
|
|
283
|
+
function computeProfileInfo(
|
|
284
|
+
name: string | undefined,
|
|
285
|
+
profile: SubagentProfile | undefined,
|
|
286
|
+
): string | undefined {
|
|
287
|
+
if (profile && name) return profileSummary(name, profile);
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Compute the number of tools available for a sub-agent window.
|
|
293
|
+
*/
|
|
294
|
+
function computeToolCount(
|
|
295
|
+
profile: SubagentProfile | undefined,
|
|
296
|
+
allToolNames: string[] | undefined,
|
|
297
|
+
): number {
|
|
298
|
+
if (profile?.noTools) return 0;
|
|
299
|
+
return profile?.tools?.length ?? allToolNames?.length ?? 0;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Create a SubAgentWindow for a single task.
|
|
304
|
+
*/
|
|
305
|
+
function createTaskWindow(
|
|
306
|
+
task: SubAgentTask,
|
|
307
|
+
rp: ResolvedProfileEntry,
|
|
308
|
+
allToolNames: string[] | undefined,
|
|
309
|
+
): SubAgentWindow {
|
|
310
|
+
const sessionId = task.resume || randomUUID().replace(/-/g, "").slice(0, 16);
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
name: task.name,
|
|
314
|
+
sessionId,
|
|
315
|
+
status: "running",
|
|
316
|
+
lines: [],
|
|
317
|
+
allMessages: [],
|
|
318
|
+
exitCode: null,
|
|
319
|
+
profileName: rp.name,
|
|
320
|
+
profileInfo: computeProfileInfo(rp.name, rp.profile),
|
|
321
|
+
provider: rp.profile?.provider,
|
|
322
|
+
model: rp.profile?.model,
|
|
323
|
+
thinkingLevel: rp.profile?.thinkingLevel,
|
|
324
|
+
startedAt: Date.now(),
|
|
325
|
+
timeout: task.timeout ?? DEFAULT_TIMEOUT,
|
|
326
|
+
todoTotal: undefined,
|
|
327
|
+
todoCompleted: undefined,
|
|
328
|
+
toolCount: computeToolCount(rp.profile, allToolNames),
|
|
329
|
+
fileCount: task.files?.length ?? 0,
|
|
330
|
+
recentToolCalls: [],
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Create SubAgentWindows for all tasks in a delegation request.
|
|
336
|
+
*/
|
|
337
|
+
function createTaskWindows(
|
|
338
|
+
tasks: StaticDelegateParams["tasks"],
|
|
339
|
+
resolvedProfiles: ResolvedProfileEntry[],
|
|
340
|
+
allToolNames: string[] | undefined,
|
|
341
|
+
): SubAgentWindow[] {
|
|
342
|
+
return tasks.map((t, i) => {
|
|
343
|
+
const rp = resolvedProfiles[i];
|
|
344
|
+
return rp ? createTaskWindow(t, rp, allToolNames) : createTaskWindow(t, {}, allToolNames);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Create a SubagentSessionData for a single task.
|
|
350
|
+
*/
|
|
351
|
+
function createTaskSession(
|
|
352
|
+
task: SubAgentTask,
|
|
353
|
+
window: SubAgentWindow,
|
|
354
|
+
defaultProfile?: string,
|
|
355
|
+
): SubagentSessionData {
|
|
356
|
+
return {
|
|
357
|
+
sessionId: window.sessionId,
|
|
358
|
+
taskName: task.name,
|
|
359
|
+
prompt: task.prompt,
|
|
360
|
+
cwd: task.cwd,
|
|
361
|
+
profileName: task.profile ?? defaultProfile,
|
|
362
|
+
status: "running" as const,
|
|
363
|
+
messages: [],
|
|
364
|
+
exitCode: null,
|
|
365
|
+
startedAt: Date.now(),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── Per-Task Execution ────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
/** Shared context for running a single sub-agent task */
|
|
372
|
+
interface TaskRunContext {
|
|
373
|
+
win: SubAgentWindow;
|
|
374
|
+
session: SubagentSessionData;
|
|
375
|
+
rp: ResolvedProfileEntry;
|
|
376
|
+
profiles: Record<string, SubagentProfile>;
|
|
377
|
+
skillResolvedProfiles: Map<
|
|
378
|
+
SubagentProfile,
|
|
379
|
+
{ ok: true; profile: SubagentProfile } | { ok: false; error: string }
|
|
380
|
+
>;
|
|
381
|
+
sessionStore: Map<string, SessionRecord>;
|
|
382
|
+
taskCwd: string;
|
|
383
|
+
maxLines: number;
|
|
384
|
+
loopingToolCount: number;
|
|
385
|
+
agentDir: string;
|
|
386
|
+
extendDebounce: number;
|
|
387
|
+
emitUpdate: () => void;
|
|
388
|
+
persistSession: (session: SubagentSessionData) => void;
|
|
389
|
+
parentSignal: AbortSignal | undefined;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Mark a window and session as errored with the given message.
|
|
394
|
+
*/
|
|
395
|
+
function markTaskError(
|
|
396
|
+
win: SubAgentWindow,
|
|
397
|
+
session: SubagentSessionData,
|
|
398
|
+
errorMessage: string,
|
|
399
|
+
emitUpdate: () => void,
|
|
400
|
+
): void {
|
|
401
|
+
win.status = "error";
|
|
402
|
+
session.status = "error";
|
|
403
|
+
win.errorMessage = errorMessage;
|
|
404
|
+
session.errorMessage = errorMessage;
|
|
405
|
+
win.exitCode = 1;
|
|
406
|
+
session.exitCode = 1;
|
|
407
|
+
emitUpdate();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Check if a profile has skills that need resolution.
|
|
412
|
+
*/
|
|
413
|
+
function profileNeedsSkillResolution(profile: SubagentProfile | undefined): boolean {
|
|
414
|
+
return !!profile && (!!profile.suggestedSkills?.length || !!profile.loadSkills?.length);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Resolve pre-resolved skills for a profile, returning the enhanced profile
|
|
419
|
+
* or marking the task as errored.
|
|
420
|
+
*/
|
|
421
|
+
function resolveSkillProfile(
|
|
422
|
+
profile: SubagentProfile,
|
|
423
|
+
skillResolvedProfiles: Map<
|
|
424
|
+
SubagentProfile,
|
|
425
|
+
{ ok: true; profile: SubagentProfile } | { ok: false; error: string }
|
|
426
|
+
>,
|
|
427
|
+
win: SubAgentWindow,
|
|
428
|
+
session: SubagentSessionData,
|
|
429
|
+
emitUpdate: () => void,
|
|
430
|
+
persistSession: (session: SubagentSessionData) => void,
|
|
431
|
+
): SubagentProfile | undefined {
|
|
432
|
+
const result = skillResolvedProfiles.get(profile);
|
|
433
|
+
if (!result || !result.ok) {
|
|
434
|
+
markTaskError(win, session, result?.error ?? "Skill resolution failed", emitUpdate);
|
|
435
|
+
persistSession(session);
|
|
436
|
+
return undefined;
|
|
437
|
+
}
|
|
438
|
+
return result.profile;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Validate and resolve the profile for a single task.
|
|
443
|
+
* Returns the resolved profile with skills, or marks the task as errored.
|
|
444
|
+
*/
|
|
445
|
+
function resolveTaskProfile(ctx: TaskRunContext): SubagentProfile | undefined {
|
|
446
|
+
const { win, session, rp, profiles, emitUpdate, persistSession, skillResolvedProfiles } = ctx;
|
|
447
|
+
|
|
448
|
+
if (rp.name && !rp.profile) {
|
|
449
|
+
markTaskError(
|
|
450
|
+
win,
|
|
451
|
+
session,
|
|
452
|
+
`Unknown profile: "${rp.name}". Available profiles: ${Object.keys(profiles).join(", ") || "(none)"}`,
|
|
453
|
+
emitUpdate,
|
|
454
|
+
);
|
|
455
|
+
persistSession(session);
|
|
456
|
+
return undefined;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (profileNeedsSkillResolution(rp.profile)) {
|
|
460
|
+
return resolveSkillProfile(
|
|
461
|
+
rp.profile as SubagentProfile,
|
|
462
|
+
skillResolvedProfiles,
|
|
463
|
+
win,
|
|
464
|
+
session,
|
|
465
|
+
emitUpdate,
|
|
466
|
+
persistSession,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return rp.profile;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Build the effective prompt for a task, applying resume context and file contents.
|
|
475
|
+
*/
|
|
476
|
+
async function buildEffectivePrompt(
|
|
477
|
+
task: SubAgentTask,
|
|
478
|
+
sessionStore: Map<string, SessionRecord>,
|
|
479
|
+
taskCwd: string,
|
|
480
|
+
): Promise<string> {
|
|
481
|
+
let effectivePrompt = task.prompt;
|
|
482
|
+
|
|
483
|
+
// Format prompt for resume if applicable
|
|
484
|
+
if (task.resume) {
|
|
485
|
+
const record = sessionStore.get(task.resume);
|
|
486
|
+
if (record) {
|
|
487
|
+
const previousData = formatRunsForResume(record.runs);
|
|
488
|
+
effectivePrompt = `Previously:\n\n${previousData}\n\nInstructions:\n\n${task.prompt}`;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Prepend file contents if specified
|
|
493
|
+
if (task.files && task.files.length > 0) {
|
|
494
|
+
const fileBlocks = await Promise.all(
|
|
495
|
+
task.files.map((spec) => readFileContents(spec, task.cwd ?? taskCwd)),
|
|
496
|
+
);
|
|
497
|
+
effectivePrompt = `${fileBlocks.join("\n\n")}\n\n${effectivePrompt}`;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return effectivePrompt;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Set up idle-timer based timeout for a sub-agent task.
|
|
505
|
+
* Returns cleanup function and reset function.
|
|
506
|
+
*/
|
|
507
|
+
function setupIdleTimeout(
|
|
508
|
+
win: SubAgentWindow,
|
|
509
|
+
taskTimeout: number,
|
|
510
|
+
extendDebounce: number,
|
|
511
|
+
parentSignal: AbortSignal | undefined,
|
|
512
|
+
taskAbortController: AbortController,
|
|
513
|
+
): { cleanup: () => void; resetTimer: () => void } {
|
|
514
|
+
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
515
|
+
|
|
516
|
+
const startIdleTimer = () => {
|
|
517
|
+
idleTimer = setTimeout(
|
|
518
|
+
() => {
|
|
519
|
+
if (Date.now() - win.startedAt >= taskTimeout * 1000) {
|
|
520
|
+
taskAbortController.abort();
|
|
521
|
+
} else {
|
|
522
|
+
// Reschedule for the remaining time to ensure timeout is enforced
|
|
523
|
+
// even if no activity occurs to restart the idle timer.
|
|
524
|
+
const remaining = Math.max(taskTimeout * 1000 - (Date.now() - win.startedAt), 1);
|
|
525
|
+
idleTimer = setTimeout(() => {
|
|
526
|
+
taskAbortController.abort();
|
|
527
|
+
}, remaining);
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
extendDebounce * 1000 || 1,
|
|
531
|
+
);
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const resetTimer = () => {
|
|
535
|
+
if (idleTimer) {
|
|
536
|
+
clearTimeout(idleTimer);
|
|
537
|
+
}
|
|
538
|
+
startIdleTimer();
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
const cleanup = () => {
|
|
542
|
+
if (idleTimer) {
|
|
543
|
+
clearTimeout(idleTimer);
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
// Forward parent signal to task controller
|
|
548
|
+
const onParentAbort = () => {
|
|
549
|
+
taskAbortController.abort();
|
|
550
|
+
};
|
|
551
|
+
if (parentSignal?.aborted) {
|
|
552
|
+
taskAbortController.abort();
|
|
553
|
+
} else if (parentSignal) {
|
|
554
|
+
parentSignal.addEventListener("abort", onParentAbort, { once: true });
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Start the idle timer immediately
|
|
558
|
+
startIdleTimer();
|
|
559
|
+
|
|
560
|
+
return {
|
|
561
|
+
cleanup: () => {
|
|
562
|
+
cleanup();
|
|
563
|
+
if (parentSignal) {
|
|
564
|
+
parentSignal.removeEventListener("abort", onParentAbort);
|
|
565
|
+
}
|
|
566
|
+
},
|
|
567
|
+
resetTimer,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Handle post-run result: detect loops and timeouts.
|
|
573
|
+
*/
|
|
574
|
+
function handlePostRunResult(
|
|
575
|
+
loopDetected: boolean,
|
|
576
|
+
taskAbortController: AbortController,
|
|
577
|
+
parentSignal: AbortSignal | undefined,
|
|
578
|
+
win: SubAgentWindow,
|
|
579
|
+
session: SubagentSessionData,
|
|
580
|
+
emitUpdate: () => void,
|
|
581
|
+
): void {
|
|
582
|
+
if (loopDetected) {
|
|
583
|
+
markTaskError(win, session, LOOP_DETECTED_MESSAGE, emitUpdate);
|
|
584
|
+
win.completedAt = Date.now();
|
|
585
|
+
taskAbortController.abort();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Check if timeout caused the abort (not loop detection, not parent abort)
|
|
589
|
+
if (!loopDetected && taskAbortController.signal.aborted && !parentSignal?.aborted) {
|
|
590
|
+
const elapsedSeconds = Math.round((Date.now() - win.startedAt) / 1000);
|
|
591
|
+
markTaskError(
|
|
592
|
+
win,
|
|
593
|
+
session,
|
|
594
|
+
`Timed out after ${elapsedSeconds}s. Consider resuming with a longer timeout.`,
|
|
595
|
+
emitUpdate,
|
|
596
|
+
);
|
|
597
|
+
win.completedAt = Date.now();
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Run a single sub-agent task within a delegation batch.
|
|
603
|
+
*/
|
|
604
|
+
async function runSingleTask(task: SubAgentTask, ctx: TaskRunContext): Promise<void> {
|
|
605
|
+
const { win, session, emitUpdate, persistSession } = ctx;
|
|
606
|
+
const skillResolvedProfile = resolveTaskProfile(ctx);
|
|
607
|
+
// resolveTaskProfile returns undefined both when no profile is needed
|
|
608
|
+
// (normal) and when an error occurred (unknown profile, skill failure).
|
|
609
|
+
// Only bail when the window was marked as errored.
|
|
610
|
+
if (win.status === "error") return;
|
|
611
|
+
|
|
612
|
+
const effectivePrompt = await buildEffectivePrompt(task, ctx.sessionStore, ctx.taskCwd);
|
|
613
|
+
const effectiveTask = { ...task, prompt: effectivePrompt };
|
|
614
|
+
|
|
615
|
+
// Create per-task timeout
|
|
616
|
+
const taskTimeout = Math.max(1, task.timeout ?? DEFAULT_TIMEOUT);
|
|
617
|
+
const taskAbortController = new AbortController();
|
|
618
|
+
const { cleanup, resetTimer } = setupIdleTimeout(
|
|
619
|
+
win,
|
|
620
|
+
taskTimeout,
|
|
621
|
+
ctx.extendDebounce,
|
|
622
|
+
ctx.parentSignal,
|
|
623
|
+
taskAbortController,
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
const wrappedEmitUpdate = () => {
|
|
627
|
+
emitUpdate();
|
|
628
|
+
resetTimer();
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
// eslint-disable-next-line no-useless-assignment
|
|
632
|
+
let loopDetected = false;
|
|
633
|
+
|
|
634
|
+
try {
|
|
635
|
+
const result = await runSubAgent({
|
|
636
|
+
task: effectiveTask,
|
|
637
|
+
win,
|
|
638
|
+
maxLines: ctx.maxLines,
|
|
639
|
+
signal: taskAbortController.signal,
|
|
640
|
+
onUpdate: wrappedEmitUpdate,
|
|
641
|
+
session,
|
|
642
|
+
profile: skillResolvedProfile,
|
|
643
|
+
loopingToolCount: ctx.loopingToolCount,
|
|
644
|
+
agentDir: ctx.agentDir,
|
|
645
|
+
});
|
|
646
|
+
loopDetected = result.loopDetected;
|
|
647
|
+
} finally {
|
|
648
|
+
cleanup();
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
handlePostRunResult(
|
|
652
|
+
loopDetected,
|
|
653
|
+
taskAbortController,
|
|
654
|
+
ctx.parentSignal,
|
|
655
|
+
win,
|
|
656
|
+
session,
|
|
657
|
+
emitUpdate,
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
// Persist session data after completion/error
|
|
661
|
+
persistSession(session);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ── Summary Building ──────────────────────────────────────────────────
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Build summary lines for all completed sub-agent windows.
|
|
668
|
+
*/
|
|
669
|
+
function buildSummaryLines(windows: SubAgentWindow[]): string[] {
|
|
670
|
+
const summaryLines: string[] = [];
|
|
671
|
+
for (const win of windows) {
|
|
672
|
+
const icon = win.status === "completed" ? "✓" : "✗";
|
|
673
|
+
let line = `${icon} ${win.name}: ${win.status} (session: ${win.sessionId})`;
|
|
674
|
+
if (win.errorMessage) {
|
|
675
|
+
line += ` — ${win.errorMessage}`;
|
|
676
|
+
}
|
|
677
|
+
if (win.profileName) {
|
|
678
|
+
line += ` (${win.profileInfo ?? win.profileName})`;
|
|
679
|
+
}
|
|
680
|
+
summaryLines.push(line);
|
|
681
|
+
}
|
|
682
|
+
return summaryLines;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ── Tool Registration ─────────────────────────────────────────────────
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Persist session data to the main agent's session tree.
|
|
689
|
+
*/
|
|
690
|
+
function persistSession(pi: ExtensionAPI, session: SubagentSessionData): void {
|
|
691
|
+
try {
|
|
692
|
+
pi.appendEntry(CUSTOM_ENTRY_TYPE, serializeSessionData(session));
|
|
693
|
+
} catch (err) {
|
|
694
|
+
// Persistence should never break delegation
|
|
695
|
+
console.warn("[pi-subagents] Failed to persist session data:", err);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Execute the delegate_to_subagents tool: resolve profiles, create windows/sessions,
|
|
701
|
+
* run tasks in parallel with concurrency limiting, and return results.
|
|
702
|
+
*/
|
|
703
|
+
async function executeDelegate(
|
|
704
|
+
pi: ExtensionAPI,
|
|
705
|
+
sessionStore: Map<string, SessionRecord>,
|
|
706
|
+
registerSession: (session: SubagentSessionData) => void,
|
|
707
|
+
getActiveSessionIds: () => Set<string>,
|
|
708
|
+
_params: StaticDelegateParams,
|
|
709
|
+
signal: AbortSignal | undefined,
|
|
710
|
+
onUpdate:
|
|
711
|
+
| ((update: {
|
|
712
|
+
content: Array<{ type: "text"; text: string }>;
|
|
713
|
+
details: WindowedSubagentDetails | undefined;
|
|
714
|
+
}) => void)
|
|
715
|
+
| undefined,
|
|
716
|
+
ctx: { cwd: string },
|
|
717
|
+
): Promise<{ content: Array<{ type: "text"; text: string }>; details: WindowedSubagentDetails }> {
|
|
718
|
+
const [maxLines, extendDebounce, loopingToolCount] = await Promise.all([
|
|
719
|
+
loadMaxLinesPerWindow(ctx.cwd),
|
|
720
|
+
loadExtendTimeoutDebounce(ctx.cwd),
|
|
721
|
+
loadLoopingToolCount(ctx.cwd),
|
|
722
|
+
]);
|
|
723
|
+
|
|
724
|
+
const { profiles, resolvedProfiles, skillResolvedProfiles, allToolNames, agentDir } =
|
|
725
|
+
await resolveTaskProfiles(_params, ctx.cwd, pi);
|
|
726
|
+
|
|
727
|
+
validateResumeParams(_params.tasks, sessionStore, getActiveSessionIds);
|
|
728
|
+
|
|
729
|
+
const windows = createTaskWindows(_params.tasks, resolvedProfiles, allToolNames);
|
|
730
|
+
const sessions = _params.tasks.map((t, i) => {
|
|
731
|
+
const win = windows[i] ?? windows[0];
|
|
732
|
+
if (!win) throw new Error("No window available for task");
|
|
733
|
+
return createTaskSession(t, win, _params.profile);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
for (const session of sessions) {
|
|
737
|
+
registerSession(session);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const makeDetails = (): WindowedSubagentDetails => ({
|
|
741
|
+
windows,
|
|
742
|
+
maxLinesPerWindow: maxLines,
|
|
743
|
+
globalStatus: windows.every((w) => w.status !== "running") ? "done" : "running",
|
|
744
|
+
sessionIds: windows.map((w) => w.sessionId),
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
const emitUpdate = () => {
|
|
748
|
+
if (onUpdate) {
|
|
749
|
+
onUpdate({
|
|
750
|
+
content: [{ type: "text", text: getSummaryText(windows) }],
|
|
751
|
+
details: makeDetails(),
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
const timerInterval = setInterval(() => {
|
|
757
|
+
if (windows.some((w) => w.status === "running")) {
|
|
758
|
+
emitUpdate();
|
|
759
|
+
}
|
|
760
|
+
}, 1000);
|
|
761
|
+
|
|
762
|
+
try {
|
|
763
|
+
await mapWithConcurrencyLimit(_params.tasks, MAX_CONCURRENCY, async (task, index) => {
|
|
764
|
+
const win = windows[index];
|
|
765
|
+
const session = sessions[index];
|
|
766
|
+
const rp = resolvedProfiles[index];
|
|
767
|
+
if (!win || !session || !rp) return;
|
|
768
|
+
|
|
769
|
+
await runSingleTask(task, {
|
|
770
|
+
win,
|
|
771
|
+
session,
|
|
772
|
+
rp: rp,
|
|
773
|
+
profiles,
|
|
774
|
+
skillResolvedProfiles,
|
|
775
|
+
sessionStore,
|
|
776
|
+
taskCwd: ctx.cwd,
|
|
777
|
+
maxLines,
|
|
778
|
+
loopingToolCount,
|
|
779
|
+
agentDir,
|
|
780
|
+
extendDebounce,
|
|
781
|
+
emitUpdate,
|
|
782
|
+
persistSession: (s) => {
|
|
783
|
+
persistSession(pi, s);
|
|
784
|
+
},
|
|
785
|
+
parentSignal: signal,
|
|
786
|
+
});
|
|
787
|
+
});
|
|
788
|
+
} finally {
|
|
789
|
+
clearInterval(timerInterval);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
return {
|
|
793
|
+
content: [{ type: "text", text: buildSummaryLines(windows).join("\n") }],
|
|
794
|
+
details: makeDetails(),
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Register the delegate_to_subagents tool.
|
|
800
|
+
*/
|
|
801
|
+
export function registerDelegateTool(
|
|
802
|
+
pi: ExtensionAPI,
|
|
803
|
+
sessionStore: Map<string, SessionRecord>,
|
|
804
|
+
registerSession: (session: SubagentSessionData) => void,
|
|
805
|
+
getActiveSessionIds: () => Set<string>,
|
|
806
|
+
): void {
|
|
807
|
+
pi.registerTool({
|
|
808
|
+
name: "delegate_to_subagents",
|
|
809
|
+
label: "Delegate to Sub-agents",
|
|
810
|
+
description: [
|
|
811
|
+
"Spawn one or more parallel sub-agents to work on separate tasks.",
|
|
812
|
+
"Each sub-agent runs in an isolated pi process with its own context window.",
|
|
813
|
+
"Live progress from each sub-agent is shown in a rolling window in the TUI.",
|
|
814
|
+
"Optionally specify a profile name to pre-configure provider/model, system prompt,",
|
|
815
|
+
"thinking level, and other model settings. Profiles are defined as individual .md files",
|
|
816
|
+
"in ~/.pi/agent/agent-profiles/ (global) or .pi/agent-profiles/ (project-local). A top-level profile parameter sets a default for all tasks;",
|
|
817
|
+
"each task can override with its own profile.",
|
|
818
|
+
"Returns session IDs for each task that can be used with get_subagent_output",
|
|
819
|
+
"and get_subagent_session to retrieve results. Each task supports an optional `timeout`",
|
|
820
|
+
"parameter (in seconds, default 600). Timeouts auto-extend while the sub-agent is active — the",
|
|
821
|
+
"sub-agent is only killed after it goes idle past the timeout (configurable via settings).",
|
|
822
|
+
"Each task supports an optional `resume` parameter referencing a previous session",
|
|
823
|
+
"ID. The resumed agent receives the prior session's transcript as context.",
|
|
824
|
+
"Each task supports an optional `files` parameter — an array of file paths or file spec",
|
|
825
|
+
"objects to read and prepend to the prompt. Accepts strings, { path, start?, end? }",
|
|
826
|
+
"for line ranges, { path, tail: N } for the last N lines, or { path, head: N } for",
|
|
827
|
+
"the first N lines. Missing, unreadable, or oversized files produce a descriptive placeholder instead of failing.",
|
|
828
|
+
].join(" "),
|
|
829
|
+
parameters: DelegateParams,
|
|
830
|
+
promptSnippet: "Use when the user wants multiple independent tasks done in parallel",
|
|
831
|
+
promptGuidelines: [
|
|
832
|
+
"Use delegate_to_subagents when the user asks for multiple independent tasks.\n",
|
|
833
|
+
"Each task gets its own isolated pi sub-agent process with full tool access.\n",
|
|
834
|
+
"Provide a descriptive `name` for each task so the TUI window is labeled.\n",
|
|
835
|
+
"Use the `profile` parameter (top-level or per-task) to select a named subagent profile",
|
|
836
|
+
"that pre-configures provider/model, system prompt, thinking level, and other settings.\n",
|
|
837
|
+
'When the user mentions a specific agent role like "use the code-reviewer profile"',
|
|
838
|
+
'or "run this as the researcher", set the profile field accordingly.\n',
|
|
839
|
+
"After delegate_to_subagents completes, use get_subagent_output to retrieve each sub-agent's",
|
|
840
|
+
"final text output. Use get_subagent_session for the full session transcript if needed.\n",
|
|
841
|
+
"Use the `timeout` per-task parameter to set a time limit in seconds. Default is 600s (10 min).\n",
|
|
842
|
+
"Timeouts auto-extend while the sub-agent is actively producing output.\n",
|
|
843
|
+
"Use the `resume` parameter to continue work from a previous sub-agent session.\n",
|
|
844
|
+
"You can only resume sessions that are completed or errored (not running).\n",
|
|
845
|
+
"Use the `files` parameter to provide file context to sub-agents without embedding large file contents directly in the prompt string.\n",
|
|
846
|
+
],
|
|
847
|
+
|
|
848
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
849
|
+
return executeDelegate(
|
|
850
|
+
pi,
|
|
851
|
+
sessionStore,
|
|
852
|
+
registerSession,
|
|
853
|
+
getActiveSessionIds,
|
|
854
|
+
params,
|
|
855
|
+
signal,
|
|
856
|
+
onUpdate,
|
|
857
|
+
ctx,
|
|
858
|
+
);
|
|
859
|
+
},
|
|
860
|
+
|
|
861
|
+
// ── renderCall ─────────────────────────────────────────────────
|
|
862
|
+
renderCall: renderDelegateCall,
|
|
863
|
+
|
|
864
|
+
// ── renderResult: Live rolling window display ──────────────────
|
|
865
|
+
renderResult: renderDelegateResult,
|
|
866
|
+
});
|
|
867
|
+
}
|