@jerryan/pi-subagent-tools 0.1.1 → 0.3.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/CHANGELOG.md +70 -9
- package/LICENSE +21 -21
- package/README.md +49 -17
- package/agents.ts +878 -0
- package/index.ts +9 -419
- package/package.json +21 -12
- package/prompts/delegate.md +6 -6
- package/prompts/explore.md +1 -1
- package/prompts/review.md +1 -1
- package/render.ts +87 -0
- package/sandbox-bash.ts +148 -0
- package/tui.ts +16 -9
- package/ui-bridge.ts +198 -0
- package/spawn.ts +0 -262
package/agents.ts
ADDED
|
@@ -0,0 +1,878 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent manager — in-process subagent sessions with follow-up support.
|
|
3
|
+
*
|
|
4
|
+
* Each subagent is an AgentSession created via the pi SDK, running in the
|
|
5
|
+
* parent's process. Sessions are in-memory (SessionManager.inMemory) — no
|
|
6
|
+
* backing files, the in-process equivalent of --no-session.
|
|
7
|
+
*
|
|
8
|
+
* Lifetime
|
|
9
|
+
* ────────
|
|
10
|
+
* Agents live in a registry keyed by id ("delegate-1", "review-2", ...).
|
|
11
|
+
* Cleanup is recency-based, not count-based:
|
|
12
|
+
*
|
|
13
|
+
* - The owning session's turn counter advances on every turn_end.
|
|
14
|
+
* - An agent records lastActiveTurn at spawn and when each run completes.
|
|
15
|
+
* - A sweep at turn_end disposes agents idle for more than
|
|
16
|
+
* PROTECTION_TURNS turns. There is no cap — any number of recently
|
|
17
|
+
* active agents survive.
|
|
18
|
+
* - Running agents (session.isStreaming) are never disposed. With
|
|
19
|
+
* turn-scoped execution this is structural — a turn cannot end while
|
|
20
|
+
* its tool calls are still running — but the guard also covers a
|
|
21
|
+
* future background mode.
|
|
22
|
+
* - All agents are disposed when the owning session shuts down.
|
|
23
|
+
*
|
|
24
|
+
* Ownership
|
|
25
|
+
* ─────────
|
|
26
|
+
* A delegate child gets its own manager (own id namespace, own turn
|
|
27
|
+
* tracking), stored on the spawning entry. Disposing an entry cascades to
|
|
28
|
+
* its child manager, so no session in the tree outlives its owner.
|
|
29
|
+
*
|
|
30
|
+
* Recursion
|
|
31
|
+
* ─────────
|
|
32
|
+
* Structural, not env-based. Review/explore children are created with
|
|
33
|
+
* noExtensions: true — they get exactly the tools injected here (read +
|
|
34
|
+
* sandboxed bash) and are leaves. Delegate children discover user/project
|
|
35
|
+
* extensions like a fresh pi (guard extensions included), minus this
|
|
36
|
+
* extension itself — self-exclusion is the recursion guard — and get
|
|
37
|
+
* review/explore/follow_up via an inline extension factory; their
|
|
38
|
+
* delegate tool is registered but rejects.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import * as fs from "node:fs";
|
|
42
|
+
import * as path from "node:path";
|
|
43
|
+
import { fileURLToPath } from "node:url";
|
|
44
|
+
import { Type, type TObject } from "@sinclair/typebox";
|
|
45
|
+
import {
|
|
46
|
+
createAgentSession,
|
|
47
|
+
DefaultResourceLoader,
|
|
48
|
+
getAgentDir,
|
|
49
|
+
ModelRuntime,
|
|
50
|
+
SessionManager,
|
|
51
|
+
SettingsManager,
|
|
52
|
+
CONFIG_DIR_NAME,
|
|
53
|
+
type AgentSession,
|
|
54
|
+
type AgentToolResult,
|
|
55
|
+
type ExtensionAPI,
|
|
56
|
+
type ExtensionContext,
|
|
57
|
+
type ToolDefinition,
|
|
58
|
+
} from "@earendil-works/pi-coding-agent";
|
|
59
|
+
import { createUIBridge } from "./ui-bridge.ts";
|
|
60
|
+
import { sandboxBashTool } from "./sandbox-bash.ts";
|
|
61
|
+
import { renderSubagentCall, renderSubagentResult } from "./render.ts";
|
|
62
|
+
import { shortenPath, truncate, TOOL_LINE_PREFIX, type UsageStats } from "./tui.ts";
|
|
63
|
+
|
|
64
|
+
const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
65
|
+
const PROMPTS_DIR = path.resolve(EXTENSION_DIR, "prompts");
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Roles — everything that distinguishes delegate/review/explore children
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
export type AgentRole = "delegate" | "review" | "explore";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Tool allowlist for read-only child roles. "bash" here denotes the
|
|
75
|
+
* sandboxed read-only shell injected via customTools (custom tools shadow
|
|
76
|
+
* builtins of the same name — fail-closed).
|
|
77
|
+
*/
|
|
78
|
+
export const READONLY_TOOLS = ["read", "bash"];
|
|
79
|
+
|
|
80
|
+
interface RoleConfig {
|
|
81
|
+
promptFile: string;
|
|
82
|
+
/** Built-in tool allowlist. Undefined = pi defaults (full access). */
|
|
83
|
+
tools?: string[];
|
|
84
|
+
customTools?: ToolDefinition<any>[];
|
|
85
|
+
/** Whether children of this role get the (guarded) subagent tool surface. */
|
|
86
|
+
childExtension: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* Whether the child discovers and loads user/project extensions like a
|
|
89
|
+
* fresh pi would. True for delegate (a worker needs the parent's full
|
|
90
|
+
* environment — tools, guards); false for read-only leaf agents (minimal
|
|
91
|
+
* surface is the point).
|
|
92
|
+
*/
|
|
93
|
+
discoverExtensions: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const ROLES: Record<AgentRole, RoleConfig> = {
|
|
97
|
+
delegate: {
|
|
98
|
+
promptFile: path.join(PROMPTS_DIR, "delegate.md"),
|
|
99
|
+
childExtension: true,
|
|
100
|
+
discoverExtensions: true,
|
|
101
|
+
},
|
|
102
|
+
review: {
|
|
103
|
+
promptFile: path.join(PROMPTS_DIR, "review.md"),
|
|
104
|
+
tools: READONLY_TOOLS,
|
|
105
|
+
customTools: [sandboxBashTool],
|
|
106
|
+
childExtension: false,
|
|
107
|
+
discoverExtensions: false,
|
|
108
|
+
},
|
|
109
|
+
explore: {
|
|
110
|
+
promptFile: path.join(PROMPTS_DIR, "explore.md"),
|
|
111
|
+
tools: READONLY_TOOLS,
|
|
112
|
+
customTools: [sandboxBashTool],
|
|
113
|
+
childExtension: false,
|
|
114
|
+
discoverExtensions: false,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Agents idle for more than this many owning-session turns are disposed. */
|
|
119
|
+
export const PROTECTION_TURNS = 10;
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Parameter schemas
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
const TaskParam = Type.String({
|
|
126
|
+
description: "Task to delegate to the subagent",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const SkillsParam = Type.Optional(
|
|
130
|
+
Type.Array(Type.String(), {
|
|
131
|
+
description: "Optional startup skills to load (paths, like --skill)",
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const CwdParam = Type.Optional(
|
|
136
|
+
Type.String({
|
|
137
|
+
description:
|
|
138
|
+
"Working directory for the subagent. Defaults to the parent's cwd.",
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
export const ReviewParams = Type.Object({
|
|
143
|
+
task: TaskParam,
|
|
144
|
+
skills: SkillsParam,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
export const ExploreParams = Type.Object({
|
|
148
|
+
task: TaskParam,
|
|
149
|
+
cwd: Type.String({
|
|
150
|
+
description:
|
|
151
|
+
"Working directory for the explorer. Required — the explorer runs in the target project to pick up its settings, skills, and context files.",
|
|
152
|
+
}),
|
|
153
|
+
skills: SkillsParam,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
export const DelegateParams = Type.Object({
|
|
157
|
+
task: TaskParam,
|
|
158
|
+
cwd: CwdParam,
|
|
159
|
+
skills: SkillsParam,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
export const FollowUpParams = Type.Object({
|
|
163
|
+
agent: Type.String({
|
|
164
|
+
description:
|
|
165
|
+
'Agent id from a previous spawn or follow_up result (e.g. "delegate-1"). The agent continues its session with full context and retains its original role, tools, and working directory.',
|
|
166
|
+
}),
|
|
167
|
+
task: Type.String({
|
|
168
|
+
description: "Follow-up task or question for the agent",
|
|
169
|
+
}),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
/** Shared shape of all spawn-tool parameters (each schema is a subset). */
|
|
173
|
+
interface SpawnToolParams {
|
|
174
|
+
task: string;
|
|
175
|
+
cwd?: string;
|
|
176
|
+
skills?: string[];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Types
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
export interface AgentEntry {
|
|
184
|
+
id: string;
|
|
185
|
+
session: AgentSession;
|
|
186
|
+
bridge: ProgressBridge;
|
|
187
|
+
childManager?: AgentManager;
|
|
188
|
+
lastActiveTurn: number;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface SpawnAgentOptions {
|
|
192
|
+
role: AgentRole;
|
|
193
|
+
task: string;
|
|
194
|
+
cwd: string;
|
|
195
|
+
skills?: string[];
|
|
196
|
+
ctx: ExtensionContext;
|
|
197
|
+
signal?: AbortSignal;
|
|
198
|
+
onUpdate?: (result: AgentToolResult<any>) => void;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Everything needed to create a child session. One named type shared by the
|
|
203
|
+
* production default and the test seam — with two separate signatures the
|
|
204
|
+
* two sides could drift apart; with one params type, drift is a compile
|
|
205
|
+
* error.
|
|
206
|
+
*/
|
|
207
|
+
export interface SpawnSessionParams {
|
|
208
|
+
opts: SpawnAgentOptions;
|
|
209
|
+
id: string;
|
|
210
|
+
runtime: () => Promise<ModelRuntime>;
|
|
211
|
+
childManager: AgentManager | undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface FollowUpOptions {
|
|
215
|
+
agent: string;
|
|
216
|
+
task: string;
|
|
217
|
+
signal?: AbortSignal;
|
|
218
|
+
onUpdate?: (result: AgentToolResult<any>) => void;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface AgentManagerDeps {
|
|
222
|
+
/** Shared model runtime. Defaults to a process-wide lazy singleton. */
|
|
223
|
+
runtime?: () => Promise<ModelRuntime>;
|
|
224
|
+
/** Test seam: override session creation. */
|
|
225
|
+
spawnSession?: (params: SpawnSessionParams) => Promise<AgentSession>;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let sharedRuntime: Promise<ModelRuntime> | undefined;
|
|
229
|
+
|
|
230
|
+
function defaultRuntime(): Promise<ModelRuntime> {
|
|
231
|
+
if (!sharedRuntime) sharedRuntime = ModelRuntime.create();
|
|
232
|
+
return sharedRuntime;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Throttle (leading + trailing)
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
function createThrottle(fn: () => void, interval: number) {
|
|
240
|
+
let last = 0;
|
|
241
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
242
|
+
return {
|
|
243
|
+
trigger() {
|
|
244
|
+
const now = Date.now();
|
|
245
|
+
if (now - last >= interval) {
|
|
246
|
+
last = now;
|
|
247
|
+
fn();
|
|
248
|
+
} else if (!timer) {
|
|
249
|
+
timer = setTimeout(() => {
|
|
250
|
+
timer = null;
|
|
251
|
+
last = Date.now();
|
|
252
|
+
fn();
|
|
253
|
+
}, interval - (now - last));
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
cancel() {
|
|
257
|
+
if (timer) {
|
|
258
|
+
clearTimeout(timer);
|
|
259
|
+
timer = null;
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Progress bridge — translates session events into tool progress updates
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
const MAX_PREV_LINES = 20;
|
|
270
|
+
const UPDATE_INTERVAL = 200;
|
|
271
|
+
|
|
272
|
+
export class ProgressBridge {
|
|
273
|
+
private previousTurnsText = "";
|
|
274
|
+
private streamText = "";
|
|
275
|
+
private turnCount = 0;
|
|
276
|
+
private tokens = { input: 0, output: 0 };
|
|
277
|
+
private startTime = Date.now();
|
|
278
|
+
private onUpdate?: (result: AgentToolResult<any>) => void;
|
|
279
|
+
private throttle = createThrottle(() => this.doUpdate(), UPDATE_INTERVAL);
|
|
280
|
+
|
|
281
|
+
reset(onUpdate?: (result: AgentToolResult<any>) => void) {
|
|
282
|
+
this.throttle.cancel();
|
|
283
|
+
this.previousTurnsText = "";
|
|
284
|
+
this.streamText = "";
|
|
285
|
+
this.turnCount = 0;
|
|
286
|
+
this.tokens = { input: 0, output: 0 };
|
|
287
|
+
this.startTime = Date.now();
|
|
288
|
+
this.onUpdate = onUpdate;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
usage(): UsageStats {
|
|
292
|
+
return {
|
|
293
|
+
turns: this.turnCount,
|
|
294
|
+
input: this.tokens.input,
|
|
295
|
+
output: this.tokens.output,
|
|
296
|
+
durationMs: Date.now() - this.startTime,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private displayText() {
|
|
301
|
+
return (
|
|
302
|
+
this.previousTurnsText +
|
|
303
|
+
(this.previousTurnsText && this.streamText ? "\n" : "") +
|
|
304
|
+
this.streamText
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private pushPrev(text: string) {
|
|
309
|
+
if (!text) return;
|
|
310
|
+
this.previousTurnsText = this.previousTurnsText
|
|
311
|
+
? this.previousTurnsText + "\n" + text
|
|
312
|
+
: text;
|
|
313
|
+
const lines = this.previousTurnsText.split("\n");
|
|
314
|
+
if (lines.length > MAX_PREV_LINES) {
|
|
315
|
+
this.previousTurnsText = lines.slice(-MAX_PREV_LINES).join("\n");
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private doUpdate() {
|
|
320
|
+
if (!this.onUpdate) return;
|
|
321
|
+
this.onUpdate({
|
|
322
|
+
content: [{ type: "text", text: this.displayText() }],
|
|
323
|
+
details: { usage: this.usage() },
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Feed a session event. */
|
|
328
|
+
handle(event: any) {
|
|
329
|
+
if (event.type === "message_update" && event.assistantMessageEvent) {
|
|
330
|
+
const delta = event.assistantMessageEvent;
|
|
331
|
+
if (delta.type === "text_delta" || delta.type === "thinking_delta") {
|
|
332
|
+
this.streamText += delta.delta;
|
|
333
|
+
this.throttle.trigger();
|
|
334
|
+
}
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (event.type === "message_end" && event.message) {
|
|
339
|
+
const msg = event.message;
|
|
340
|
+
if (msg.role !== "assistant") return;
|
|
341
|
+
this.turnCount++;
|
|
342
|
+
if (msg.usage) {
|
|
343
|
+
this.tokens.input += msg.usage.input || 0;
|
|
344
|
+
this.tokens.output += msg.usage.output || 0;
|
|
345
|
+
}
|
|
346
|
+
this.pushPrev(this.streamText);
|
|
347
|
+
this.streamText = "";
|
|
348
|
+
this.throttle.trigger();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (event.type === "tool_execution_start") {
|
|
353
|
+
this.pushPrev(formatToolCallLine(event.toolName, event.args));
|
|
354
|
+
this.throttle.trigger();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** One-line summary of a tool call: "▸ name k=v k=v", capped at 80 chars. */
|
|
360
|
+
function formatToolCallLine(name: string, args: unknown): string {
|
|
361
|
+
const parts = [`${TOOL_LINE_PREFIX} ${name}`];
|
|
362
|
+
if (args && typeof args === "object" && !Array.isArray(args)) {
|
|
363
|
+
const entries = Object.entries(args);
|
|
364
|
+
const capPerParam = entries.length > 1;
|
|
365
|
+
for (const [k, v] of entries) {
|
|
366
|
+
const val = typeof v === "string" ? v : JSON.stringify(v);
|
|
367
|
+
parts.push(`${k}=${capPerParam ? truncate(val, 40) : val}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return truncate(parts.join(" "), 80);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
// Result extraction
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
function extractResult(messages: Array<any>): { output: string; isError: boolean } {
|
|
378
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
379
|
+
const msg = messages[i];
|
|
380
|
+
if (msg.role !== "assistant") continue;
|
|
381
|
+
if (msg.stopReason === "error") {
|
|
382
|
+
return {
|
|
383
|
+
output: `Subagent failed: ${msg.errorMessage || "unknown error"}`,
|
|
384
|
+
isError: true,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
if (msg.stopReason === "aborted") {
|
|
388
|
+
return { output: "Subagent was aborted", isError: true };
|
|
389
|
+
}
|
|
390
|
+
const text = (msg.content ?? [])
|
|
391
|
+
.filter((c: any) => c.type === "text")
|
|
392
|
+
.map((c: any) => c.text)
|
|
393
|
+
.join("");
|
|
394
|
+
if (text) return { output: text, isError: false };
|
|
395
|
+
}
|
|
396
|
+
return { output: "(no output)", isError: false };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
// Error helpers
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Signal a tool error. In current pi, errors are reported by throwing — the
|
|
405
|
+
* agent loop catches and marks the tool result as an error with this message.
|
|
406
|
+
* (Returning isError in the result object is no longer supported.)
|
|
407
|
+
*/
|
|
408
|
+
function fail(text: string): never {
|
|
409
|
+
throw new Error(text);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function assertCwd(cwd: string | undefined): asserts cwd is string {
|
|
413
|
+
if (!cwd) fail("Cannot spawn subagent: cwd is required.");
|
|
414
|
+
let stat: fs.Stats;
|
|
415
|
+
try {
|
|
416
|
+
stat = fs.statSync(cwd);
|
|
417
|
+
} catch (err: any) {
|
|
418
|
+
fail(
|
|
419
|
+
`Cannot spawn subagent: cwd "${cwd}" does not exist or is not accessible (${err.message}).`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
if (!stat.isDirectory()) {
|
|
423
|
+
fail(`Cannot spawn subagent: "${cwd}" is not a directory.`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
// Agent manager
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
export class AgentManager {
|
|
432
|
+
private entries = new Map<string, AgentEntry>();
|
|
433
|
+
private counters = new Map<string, number>();
|
|
434
|
+
private turn = 0;
|
|
435
|
+
private runtime: () => Promise<ModelRuntime>;
|
|
436
|
+
private spawnSession?: AgentManagerDeps["spawnSession"];
|
|
437
|
+
|
|
438
|
+
constructor(deps: AgentManagerDeps = {}) {
|
|
439
|
+
this.runtime = deps.runtime ?? defaultRuntime;
|
|
440
|
+
this.spawnSession = deps.spawnSession;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Ids of live agents, in spawn order. */
|
|
444
|
+
liveIds(): string[] {
|
|
445
|
+
return [...this.entries.keys()];
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Advance the owning session's turn counter and sweep stale agents. */
|
|
449
|
+
noteTurnEnd() {
|
|
450
|
+
this.turn++;
|
|
451
|
+
for (const [id, entry] of this.entries) {
|
|
452
|
+
if (entry.session.isStreaming) continue;
|
|
453
|
+
if (this.turn - entry.lastActiveTurn > PROTECTION_TURNS) {
|
|
454
|
+
this.disposeEntry(id, entry);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Dispose all agents (owning session shutdown/replacement). */
|
|
460
|
+
disposeAll() {
|
|
461
|
+
for (const [id, entry] of this.entries) {
|
|
462
|
+
this.disposeEntry(id, entry);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private disposeEntry(id: string, entry: AgentEntry) {
|
|
467
|
+
this.entries.delete(id);
|
|
468
|
+
// Cascade before disposing the session: no grandchild outlives its owner.
|
|
469
|
+
entry.childManager?.disposeAll();
|
|
470
|
+
try {
|
|
471
|
+
entry.session.dispose();
|
|
472
|
+
} catch {
|
|
473
|
+
// Dispose is best-effort cleanup; a broken session must not jam the sweep.
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// -------------------------------------------------------------------------
|
|
478
|
+
// Spawn
|
|
479
|
+
// -------------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
async spawn(opts: SpawnAgentOptions): Promise<AgentToolResult<any>> {
|
|
482
|
+
assertCwd(opts.cwd);
|
|
483
|
+
|
|
484
|
+
const role = ROLES[opts.role];
|
|
485
|
+
const next = (this.counters.get(opts.role) ?? 0) + 1;
|
|
486
|
+
this.counters.set(opts.role, next);
|
|
487
|
+
const id = `${opts.role}-${next}`;
|
|
488
|
+
const childManager = role.childExtension
|
|
489
|
+
? new AgentManager({ runtime: this.runtime })
|
|
490
|
+
: undefined;
|
|
491
|
+
|
|
492
|
+
let session: AgentSession;
|
|
493
|
+
try {
|
|
494
|
+
session = await (this.spawnSession ?? defaultSpawnSession)({
|
|
495
|
+
opts,
|
|
496
|
+
id,
|
|
497
|
+
runtime: this.runtime,
|
|
498
|
+
childManager,
|
|
499
|
+
});
|
|
500
|
+
} catch (err: any) {
|
|
501
|
+
fail(`Failed to start subagent: ${err.message || String(err)}`);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const entry: AgentEntry = {
|
|
505
|
+
id,
|
|
506
|
+
session,
|
|
507
|
+
bridge: new ProgressBridge(),
|
|
508
|
+
childManager,
|
|
509
|
+
lastActiveTurn: this.turn,
|
|
510
|
+
};
|
|
511
|
+
session.subscribe((event) => entry.bridge.handle(event));
|
|
512
|
+
this.entries.set(id, entry);
|
|
513
|
+
|
|
514
|
+
return this.run(entry, opts.task, opts.signal, opts.onUpdate);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// -------------------------------------------------------------------------
|
|
518
|
+
// Follow-up
|
|
519
|
+
// -------------------------------------------------------------------------
|
|
520
|
+
|
|
521
|
+
async followUp(opts: FollowUpOptions): Promise<AgentToolResult<any>> {
|
|
522
|
+
const entry = this.entries.get(opts.agent);
|
|
523
|
+
if (!entry) {
|
|
524
|
+
const live = this.liveIds();
|
|
525
|
+
const hint = live.length > 0 ? ` Live agents: ${live.join(", ")}.` : " No live agents.";
|
|
526
|
+
fail(
|
|
527
|
+
`Agent "${opts.agent}" not found (expired or never existed).${hint} Spawn a fresh agent instead.`,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
return this.run(entry, opts.task, opts.signal, opts.onUpdate);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// -------------------------------------------------------------------------
|
|
534
|
+
// Run one prompt against an agent's session
|
|
535
|
+
// -------------------------------------------------------------------------
|
|
536
|
+
|
|
537
|
+
private async run(
|
|
538
|
+
entry: AgentEntry,
|
|
539
|
+
task: string,
|
|
540
|
+
signal: AbortSignal | undefined,
|
|
541
|
+
onUpdate: ((result: AgentToolResult<any>) => void) | undefined,
|
|
542
|
+
): Promise<AgentToolResult<any>> {
|
|
543
|
+
// Any engagement — even an aborted or failed run — marks the agent active.
|
|
544
|
+
entry.lastActiveTurn = this.turn;
|
|
545
|
+
entry.bridge.reset(onUpdate);
|
|
546
|
+
|
|
547
|
+
const onAbort = () => {
|
|
548
|
+
void entry.session.abort();
|
|
549
|
+
};
|
|
550
|
+
if (signal?.aborted) fail("Subagent was aborted");
|
|
551
|
+
if (signal) signal.addEventListener("abort", onAbort, { once: true });
|
|
552
|
+
|
|
553
|
+
try {
|
|
554
|
+
await entry.session.prompt(task, { expandPromptTemplates: false });
|
|
555
|
+
} catch (err: any) {
|
|
556
|
+
fail(`Subagent failed: ${err.message || String(err)}`);
|
|
557
|
+
} finally {
|
|
558
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const { output, isError } = extractResult(entry.session.messages as any[]);
|
|
562
|
+
const text = `${output}\n\n---\nagent: ${entry.id}`;
|
|
563
|
+
if (isError) fail(text);
|
|
564
|
+
return {
|
|
565
|
+
content: [{ type: "text" as const, text }],
|
|
566
|
+
details: { usage: entry.bridge.usage(), final: true },
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
// Extension discovery filter (delegate children)
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
|
|
575
|
+
export interface ChildExtensionFilterOptions {
|
|
576
|
+
/** Directory containing this extension's own files (dev/local installs). */
|
|
577
|
+
selfDir: string;
|
|
578
|
+
/** The child session's working directory. */
|
|
579
|
+
childCwd: string;
|
|
580
|
+
/** Whether the parent session treats its project as trusted. */
|
|
581
|
+
projectTrusted: boolean;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Filter the discovered extension set for a delegate child session.
|
|
586
|
+
* Pure and exported for tests.
|
|
587
|
+
*
|
|
588
|
+
* Two exclusions:
|
|
589
|
+
* 1. Self — the discovered copy of THIS extension would register the full
|
|
590
|
+
* parent tool surface (an unguarded delegate) and conflict with the
|
|
591
|
+
* inline factory's guarded tools. Self-exclusion IS the recursion
|
|
592
|
+
* guard (it replaces the subprocess era's env-var role signal).
|
|
593
|
+
* 2. Project-local extensions when the project is untrusted — mirrors
|
|
594
|
+
* pi's trust model, which the raw SDK path does not enforce.
|
|
595
|
+
*/
|
|
596
|
+
export function filterChildExtensions<T extends { path: string; resolvedPath?: string }>(
|
|
597
|
+
extensions: T[],
|
|
598
|
+
opts: ChildExtensionFilterOptions,
|
|
599
|
+
): T[] {
|
|
600
|
+
const selfDir = path.resolve(opts.selfDir);
|
|
601
|
+
const projectExtDir = path.resolve(opts.childCwd, CONFIG_DIR_NAME);
|
|
602
|
+
return extensions.filter((ext) => {
|
|
603
|
+
for (const p of [ext.path, ext.resolvedPath]) {
|
|
604
|
+
if (!p) continue;
|
|
605
|
+
// Skip pseudo-paths (e.g. "<inline:name>") — they are not filesystem
|
|
606
|
+
// locations and must not be resolved against directory prefixes.
|
|
607
|
+
if (p.startsWith("<")) continue;
|
|
608
|
+
const normalized = p.replace(/\\/g, "/");
|
|
609
|
+
if (normalized.includes("node_modules/@jerryan/pi-subagent-tools/")) return false;
|
|
610
|
+
const resolved = path.resolve(p);
|
|
611
|
+
if (resolved === selfDir || resolved.startsWith(selfDir + path.sep)) return false;
|
|
612
|
+
if (
|
|
613
|
+
!opts.projectTrusted &&
|
|
614
|
+
(resolved === projectExtDir || resolved.startsWith(projectExtDir + path.sep))
|
|
615
|
+
) {
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return true;
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// ---------------------------------------------------------------------------
|
|
624
|
+
// Default session creation (the in-process equivalent of buildSpawnArgs)
|
|
625
|
+
// ---------------------------------------------------------------------------
|
|
626
|
+
|
|
627
|
+
async function defaultSpawnSession(
|
|
628
|
+
params: SpawnSessionParams,
|
|
629
|
+
): Promise<AgentSession> {
|
|
630
|
+
const { opts, id, runtime, childManager } = params;
|
|
631
|
+
const modelRuntime = await runtime();
|
|
632
|
+
const role = ROLES[opts.role];
|
|
633
|
+
const agentDir = getAgentDir();
|
|
634
|
+
const settingsManager = SettingsManager.create(opts.cwd, agentDir, {
|
|
635
|
+
projectTrusted: opts.ctx.isProjectTrusted(),
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
const loader = new DefaultResourceLoader({
|
|
639
|
+
cwd: opts.cwd,
|
|
640
|
+
agentDir,
|
|
641
|
+
settingsManager,
|
|
642
|
+
noExtensions: !role.discoverExtensions,
|
|
643
|
+
extensionsOverride: role.discoverExtensions
|
|
644
|
+
? (base) => ({
|
|
645
|
+
...base,
|
|
646
|
+
extensions: filterChildExtensions(base.extensions, {
|
|
647
|
+
selfDir: EXTENSION_DIR,
|
|
648
|
+
childCwd: opts.cwd,
|
|
649
|
+
projectTrusted: opts.ctx.isProjectTrusted(),
|
|
650
|
+
}),
|
|
651
|
+
})
|
|
652
|
+
: undefined,
|
|
653
|
+
additionalSkillPaths: opts.skills ?? [],
|
|
654
|
+
appendSystemPrompt: [role.promptFile],
|
|
655
|
+
extensionFactories: childManager
|
|
656
|
+
? [
|
|
657
|
+
{
|
|
658
|
+
name: "pi-subagent-tools",
|
|
659
|
+
factory: (pi: ExtensionAPI) =>
|
|
660
|
+
registerChildAgentTools(pi, childManager),
|
|
661
|
+
},
|
|
662
|
+
]
|
|
663
|
+
: [],
|
|
664
|
+
});
|
|
665
|
+
await loader.reload();
|
|
666
|
+
|
|
667
|
+
const { session } = await createAgentSession({
|
|
668
|
+
cwd: opts.cwd,
|
|
669
|
+
model: opts.ctx.model,
|
|
670
|
+
thinkingLevel: opts.ctx.thinkingLevel,
|
|
671
|
+
modelRuntime,
|
|
672
|
+
tools: role.tools ? [...role.tools] : undefined,
|
|
673
|
+
customTools: role.customTools,
|
|
674
|
+
resourceLoader: loader,
|
|
675
|
+
sessionManager: SessionManager.inMemory(opts.cwd),
|
|
676
|
+
settingsManager,
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
await session.bindExtensions({
|
|
680
|
+
uiContext: createUIBridge(opts.ctx.ui, { label: id }),
|
|
681
|
+
mode: "rpc",
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
return session;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// ---------------------------------------------------------------------------
|
|
688
|
+
// Tool registration
|
|
689
|
+
// ---------------------------------------------------------------------------
|
|
690
|
+
|
|
691
|
+
interface SpawnToolConfig {
|
|
692
|
+
role: AgentRole;
|
|
693
|
+
label: string;
|
|
694
|
+
description: string;
|
|
695
|
+
promptSnippet: string;
|
|
696
|
+
promptGuidelines: string[];
|
|
697
|
+
parameters: TObject<any>;
|
|
698
|
+
resolveCwd: (params: SpawnToolParams, ctx: ExtensionContext) => string;
|
|
699
|
+
hint?: (params: SpawnToolParams) => string | undefined;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const DELEGATE_TOOL: SpawnToolConfig = {
|
|
703
|
+
role: "delegate",
|
|
704
|
+
label: "Delegate",
|
|
705
|
+
description:
|
|
706
|
+
"Delegate a task to a subagent. " +
|
|
707
|
+
"For general-purpose work that doesn't fit the review or explore tools. " +
|
|
708
|
+
"The result includes an agent id — use follow_up to continue working with the same agent.",
|
|
709
|
+
promptSnippet: "Delegate a task to a worker subagent",
|
|
710
|
+
promptGuidelines: [
|
|
711
|
+
"Use the delegate tool for non-trivial, self-contained implementation tasks — it has full tool access unlike the read-only review and explore tools.",
|
|
712
|
+
"Use follow_up with the agent id from the result to refine a subagent's work or recover from an incomplete result, instead of spawning a fresh agent.",
|
|
713
|
+
],
|
|
714
|
+
parameters: DelegateParams,
|
|
715
|
+
resolveCwd: (params, ctx) => params.cwd ?? ctx.cwd,
|
|
716
|
+
hint: (params) => (params.cwd ? shortenPath(params.cwd) : undefined),
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
const REVIEW_TOOL: SpawnToolConfig = {
|
|
720
|
+
role: "review",
|
|
721
|
+
label: "Review",
|
|
722
|
+
description:
|
|
723
|
+
"Review code changes or files in the current project. " +
|
|
724
|
+
"The reviewer is always read-only and runs in the parent's working directory. " +
|
|
725
|
+
"Use for code review, diff inspection, issue analysis, and quality checks. " +
|
|
726
|
+
"The result includes an agent id — use follow_up to ask the reviewer more questions.",
|
|
727
|
+
promptSnippet: "Review code or files with a read-only subagent",
|
|
728
|
+
promptGuidelines: [
|
|
729
|
+
"Use the review tool for code review, diff inspection, and quality checks — it is read-only and cannot modify files.",
|
|
730
|
+
],
|
|
731
|
+
parameters: ReviewParams,
|
|
732
|
+
resolveCwd: (_params, ctx) => ctx.cwd,
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
const EXPLORE_TOOL: SpawnToolConfig = {
|
|
736
|
+
role: "explore",
|
|
737
|
+
label: "Explore",
|
|
738
|
+
description:
|
|
739
|
+
"Explore a project directory to understand its structure, patterns, and key files. " +
|
|
740
|
+
"The explorer is always read-only and runs in the specified working directory. " +
|
|
741
|
+
"Use for mapping codebases, scouting dependencies, or understanding unfamiliar projects. " +
|
|
742
|
+
"The result includes an agent id — use follow_up to dig deeper with the same explorer.",
|
|
743
|
+
promptSnippet: "Explore a project directory with a read-only subagent",
|
|
744
|
+
promptGuidelines: [
|
|
745
|
+
"Use the explore tool to map unfamiliar codebases or scout dependencies — it runs read-only in the specified directory.",
|
|
746
|
+
],
|
|
747
|
+
parameters: ExploreParams,
|
|
748
|
+
resolveCwd: (params) => params.cwd!,
|
|
749
|
+
hint: (params) => shortenPath(params.cwd!),
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Build a spawn tool's definition. Everything is derived from the role
|
|
754
|
+
* config except `execute` — so the parent's real tool and the delegate
|
|
755
|
+
* child's rejecting guard differ by exactly one function.
|
|
756
|
+
*/
|
|
757
|
+
function spawnToolDefinition(
|
|
758
|
+
config: SpawnToolConfig,
|
|
759
|
+
execute: (
|
|
760
|
+
params: SpawnToolParams,
|
|
761
|
+
signal: AbortSignal | undefined,
|
|
762
|
+
onUpdate: ((result: AgentToolResult<any>) => void) | undefined,
|
|
763
|
+
ctx: ExtensionContext,
|
|
764
|
+
) => Promise<AgentToolResult<any>>,
|
|
765
|
+
) {
|
|
766
|
+
return {
|
|
767
|
+
name: config.role,
|
|
768
|
+
label: config.label,
|
|
769
|
+
description: config.description,
|
|
770
|
+
promptSnippet: config.promptSnippet,
|
|
771
|
+
promptGuidelines: config.promptGuidelines,
|
|
772
|
+
parameters: config.parameters,
|
|
773
|
+
execute: (
|
|
774
|
+
_toolCallId: string,
|
|
775
|
+
params: SpawnToolParams,
|
|
776
|
+
signal: AbortSignal | undefined,
|
|
777
|
+
onUpdate: ((result: AgentToolResult<any>) => void) | undefined,
|
|
778
|
+
ctx: ExtensionContext,
|
|
779
|
+
) => execute(params, signal, onUpdate, ctx),
|
|
780
|
+
renderCall: (args: SpawnToolParams, theme: any) =>
|
|
781
|
+
renderSubagentCall(
|
|
782
|
+
`${config.role} `,
|
|
783
|
+
{ task: args.task, hint: config.hint?.(args) },
|
|
784
|
+
theme,
|
|
785
|
+
),
|
|
786
|
+
renderResult: (result: any, options: any, theme: any, context: any) =>
|
|
787
|
+
renderSubagentResult(result, options, theme, context),
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function registerSpawnTool(
|
|
792
|
+
pi: ExtensionAPI,
|
|
793
|
+
manager: AgentManager,
|
|
794
|
+
config: SpawnToolConfig,
|
|
795
|
+
): void {
|
|
796
|
+
pi.registerTool(
|
|
797
|
+
spawnToolDefinition(config, (params, signal, onUpdate, ctx) =>
|
|
798
|
+
manager.spawn({
|
|
799
|
+
role: config.role,
|
|
800
|
+
task: params.task,
|
|
801
|
+
cwd: config.resolveCwd(params, ctx),
|
|
802
|
+
skills: params.skills,
|
|
803
|
+
ctx,
|
|
804
|
+
signal,
|
|
805
|
+
onUpdate,
|
|
806
|
+
}),
|
|
807
|
+
) as any,
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** The rejecting delegate tool registered in delegate children. */
|
|
812
|
+
function registerRejectingDelegateTool(pi: ExtensionAPI): void {
|
|
813
|
+
pi.registerTool(
|
|
814
|
+
spawnToolDefinition(DELEGATE_TOOL, () =>
|
|
815
|
+
fail(
|
|
816
|
+
"Delegation not available in delegate subagents. Use review or explore instead.",
|
|
817
|
+
),
|
|
818
|
+
) as any,
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function registerFollowUpTool(pi: ExtensionAPI, manager: AgentManager): void {
|
|
823
|
+
pi.registerTool({
|
|
824
|
+
name: "follow_up",
|
|
825
|
+
label: "Follow Up",
|
|
826
|
+
description:
|
|
827
|
+
"Send a follow-up task to a previously spawned subagent, continuing its session with full context. " +
|
|
828
|
+
"The agent retains its original role, tools, and working directory. " +
|
|
829
|
+
"Errors if the agent id is unknown or expired — spawn a fresh agent instead.",
|
|
830
|
+
promptSnippet: "Continue working with an existing subagent",
|
|
831
|
+
promptGuidelines: [
|
|
832
|
+
"Use follow_up to refine a subagent's work, ask questions about its findings, or recover from an incomplete or failed result — the agent keeps its full context.",
|
|
833
|
+
],
|
|
834
|
+
parameters: FollowUpParams,
|
|
835
|
+
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
|
|
836
|
+
return manager.followUp({
|
|
837
|
+
agent: params.agent,
|
|
838
|
+
task: params.task,
|
|
839
|
+
signal,
|
|
840
|
+
onUpdate,
|
|
841
|
+
});
|
|
842
|
+
},
|
|
843
|
+
renderCall: (args, theme) =>
|
|
844
|
+
renderSubagentCall("follow_up ", { task: args.task, hint: args.agent }, theme),
|
|
845
|
+
renderResult: (result, options, theme, context) =>
|
|
846
|
+
renderSubagentResult(result as any, options, theme, context),
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
function wireLifecycle(pi: ExtensionAPI, manager: AgentManager): void {
|
|
852
|
+
pi.on("turn_end", () => manager.noteTurnEnd());
|
|
853
|
+
pi.on("session_shutdown", () => manager.disposeAll());
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** Full tool surface for the parent session. */
|
|
857
|
+
export function registerAgentTools(pi: ExtensionAPI, manager: AgentManager): void {
|
|
858
|
+
wireLifecycle(pi, manager);
|
|
859
|
+
registerSpawnTool(pi, manager, DELEGATE_TOOL);
|
|
860
|
+
registerSpawnTool(pi, manager, REVIEW_TOOL);
|
|
861
|
+
registerSpawnTool(pi, manager, EXPLORE_TOOL);
|
|
862
|
+
registerFollowUpTool(pi, manager);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Guarded tool surface for delegate children: review/explore/follow_up are
|
|
867
|
+
* active; delegate is registered but rejects — the structural recursion guard.
|
|
868
|
+
*/
|
|
869
|
+
export function registerChildAgentTools(
|
|
870
|
+
pi: ExtensionAPI,
|
|
871
|
+
manager: AgentManager,
|
|
872
|
+
): void {
|
|
873
|
+
wireLifecycle(pi, manager);
|
|
874
|
+
registerRejectingDelegateTool(pi);
|
|
875
|
+
registerSpawnTool(pi, manager, REVIEW_TOOL);
|
|
876
|
+
registerSpawnTool(pi, manager, EXPLORE_TOOL);
|
|
877
|
+
registerFollowUpTool(pi, manager);
|
|
878
|
+
}
|