pi-fast-subagent 0.7.0 → 0.9.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/runner.ts ADDED
@@ -0,0 +1,371 @@
1
+ /**
2
+ * In-process subagent runner.
3
+ *
4
+ * Creates a transient AgentSession per task, streams tool/message events back
5
+ * via `onUpdate`, and enforces the per-agent `maxDepth` gate on nested calls.
6
+ */
7
+
8
+ import {
9
+ AuthStorage,
10
+ createAgentSession,
11
+ getAgentDir,
12
+ ModelRegistry,
13
+ SessionManager,
14
+ } from "@mariozechner/pi-coding-agent";
15
+
16
+ import { type AgentConfig, agentNeedsExtensions } from "./agents.js";
17
+ import { allowUiPaint, defaultLoaderPool, LoaderPool } from "./loader-pool.js";
18
+ import { summarizeToolArgs } from "./format.js";
19
+ import type { ExecutionEvent, OnUpdate, RunResult, SubagentDetails, ToolCallEntry } from "./types.js";
20
+
21
+ // ─── Auth singletons ─────────────────────────────────────────────────────────
22
+
23
+ let _authStorage: ReturnType<typeof AuthStorage.create> | null = null;
24
+ let _modelRegistry: ReturnType<typeof ModelRegistry.create> | null = null;
25
+
26
+ export function getAuth() {
27
+ if (!_authStorage) _authStorage = AuthStorage.create();
28
+ if (!_modelRegistry) _modelRegistry = ModelRegistry.create(_authStorage);
29
+ return { authStorage: _authStorage, modelRegistry: _modelRegistry };
30
+ }
31
+
32
+ // ─── Depth gating ────────────────────────────────────────────────────────────
33
+
34
+ export const DEFAULT_MAX_DEPTH = 0;
35
+ export const DEPTH_ENV = "PI_FAST_SUBAGENT_DEPTH";
36
+ export const MAX_DEPTH_ENV = "PI_FAST_SUBAGENT_MAX_DEPTH";
37
+
38
+ /**
39
+ * Pure helper: given the current nesting depth and the allowed max depth,
40
+ * decide whether a nested subagent call should be rejected.
41
+ *
42
+ * depth === 0 → top-level call, always allowed
43
+ * depth > 0 → nested call, allowed only if depth <= maxDepth
44
+ */
45
+ export function checkDepthGate(depth: number, maxDepth: number): { allowed: boolean; reason?: string } {
46
+ if (depth <= 0) return { allowed: true };
47
+ if (depth > maxDepth) {
48
+ return {
49
+ allowed: false,
50
+ reason: `Nested subagents are disabled by default. Set maxDepth: ${depth} (or higher) in the parent agent frontmatter to allow this call.`,
51
+ };
52
+ }
53
+ return { allowed: true };
54
+ }
55
+
56
+ // Module-level depth counters for nested in-process subagent calls.
57
+ let _currentDepth = 0;
58
+ let _currentMaxDepth = DEFAULT_MAX_DEPTH;
59
+
60
+ /** Read-only accessors for the current in-flight depth (used by parallel mode). */
61
+ export function getCurrentDepth(): number {
62
+ return _currentDepth;
63
+ }
64
+
65
+ // ─── runAgent ────────────────────────────────────────────────────────────────
66
+
67
+ export interface RunAgentDeps {
68
+ loaderPool?: LoaderPool;
69
+ }
70
+
71
+ export async function runAgent(
72
+ agent: AgentConfig,
73
+ task: string,
74
+ cwd: string,
75
+ modelOverride: string | undefined,
76
+ signal: AbortSignal | undefined,
77
+ onUpdate: OnUpdate | undefined,
78
+ parentDepth?: number,
79
+ deps: RunAgentDeps = {},
80
+ ): Promise<RunResult> {
81
+ const pool = deps.loaderPool ?? defaultLoaderPool;
82
+ const depth = parentDepth ?? _currentDepth;
83
+ const gate = checkDepthGate(depth, _currentMaxDepth);
84
+ if (!gate.allowed) {
85
+ return {
86
+ output: "",
87
+ exitCode: 1,
88
+ error: gate.reason,
89
+ toolCalls: [],
90
+ usage: { input: 0, output: 0, cost: 0, turns: 0 },
91
+ };
92
+ }
93
+
94
+ const bootStartedAt = Date.now();
95
+ const { authStorage, modelRegistry } = getAuth();
96
+ const agentDir = getAgentDir();
97
+ const noExtensions = !agentNeedsExtensions(agent.tools);
98
+ const coldLoader = !pool.isWarm(cwd, agentDir, noExtensions);
99
+
100
+ // Fire an immediate "running" emit so the UI draws the agent header + prompt
101
+ // before the (potentially slow) extension/session load. Without this, pi looks
102
+ // frozen while `loader.reload()` and `createAgentSession()` are in flight.
103
+ onUpdate?.({
104
+ content: [{ type: "text", text: "" }],
105
+ details: {
106
+ agentName: agent.name,
107
+ task,
108
+ usage: { input: 0, output: 0, cost: 0, turns: 0 },
109
+ running: true,
110
+ elapsedMs: 0,
111
+ model: modelOverride ?? agent.model,
112
+ toolCalls: [],
113
+ } satisfies SubagentDetails,
114
+ });
115
+ await allowUiPaint(coldLoader);
116
+
117
+ const loaderLease = await pool.acquire(
118
+ cwd,
119
+ agentDir,
120
+ noExtensions,
121
+ agent.systemPrompt || undefined,
122
+ );
123
+
124
+ let session: Awaited<ReturnType<typeof createAgentSession>>["session"];
125
+ try {
126
+ const created = await createAgentSession({
127
+ cwd,
128
+ agentDir,
129
+ sessionManager: SessionManager.inMemory(cwd),
130
+ authStorage,
131
+ modelRegistry,
132
+ resourceLoader: loaderLease.loader,
133
+ });
134
+ session = created.session;
135
+ } catch (e) {
136
+ loaderLease.release();
137
+ return {
138
+ output: "",
139
+ exitCode: 1,
140
+ error: e instanceof Error ? e.message : String(e),
141
+ toolCalls: [],
142
+ usage: { input: 0, output: 0, cost: 0, turns: 0 },
143
+ };
144
+ }
145
+
146
+ // Resolve and apply model
147
+ const modelStr = modelOverride ?? agent.model;
148
+ if (modelStr) {
149
+ const [provider, ...rest] = modelStr.split("/");
150
+ const modelId = rest.join("/");
151
+ if (provider && modelId) {
152
+ const model = modelRegistry.find(provider, modelId);
153
+ if (model) await session.setModel(model);
154
+ }
155
+ }
156
+
157
+ // Apply tools allowlist.
158
+ // "all" → no restriction (everything registered stays active)
159
+ // "none" → disable every tool
160
+ // string[] → explicit allowlist
161
+ if (agent.tools === "none") {
162
+ session.setActiveToolsByName([]);
163
+ } else if (Array.isArray(agent.tools) && agent.tools.length > 0) {
164
+ session.setActiveToolsByName(agent.tools);
165
+ }
166
+
167
+ const usage = { input: 0, output: 0, cost: 0, turns: 0 };
168
+ let lastOutput = "";
169
+ let currentDelta = "";
170
+ let detectedModel: string | undefined;
171
+ const startedAt = bootStartedAt;
172
+ const configuredModel = modelOverride ?? agent.model;
173
+ const toolCalls: ToolCallEntry[] = [];
174
+ const toolStartTimes = new Map<string, number>();
175
+ const executionEvents: ExecutionEvent[] = [];
176
+
177
+ let done = false;
178
+
179
+ function emitUpdate(): void {
180
+ if (done) return;
181
+ onUpdate?.({
182
+ content: [{ type: "text", text: currentDelta || lastOutput || "" }],
183
+ details: {
184
+ agentName: agent.name,
185
+ task,
186
+ usage,
187
+ running: true,
188
+ elapsedMs: Date.now() - startedAt,
189
+ model: detectedModel ?? configuredModel,
190
+ toolCalls: [...toolCalls],
191
+ executionEvents: [...executionEvents],
192
+ } satisfies SubagentDetails,
193
+ });
194
+ }
195
+
196
+ emitUpdate();
197
+ const heartbeat = setInterval(emitUpdate, 1000);
198
+
199
+ const unsubscribe = session.subscribe((event: any) => {
200
+ const now = Date.now();
201
+
202
+ if (event.type === "tool_execution_start") {
203
+ const startTime = now;
204
+ toolStartTimes.set(event.toolCallId, startTime);
205
+ const argSummary = summarizeToolArgs(event.toolName, event.args);
206
+ toolCalls.push({
207
+ id: event.toolCallId,
208
+ name: event.toolName,
209
+ argSummary,
210
+ });
211
+ executionEvents.push({
212
+ type: "tool_start",
213
+ toolCallId: event.toolCallId,
214
+ toolName: event.toolName,
215
+ argSummary,
216
+ timestamp: now,
217
+ });
218
+ emitUpdate();
219
+ return;
220
+ }
221
+
222
+ if (event.type === "tool_execution_end") {
223
+ const startedAtTool = toolStartTimes.get(event.toolCallId);
224
+ toolStartTimes.delete(event.toolCallId);
225
+ const resultText: string = (event.result?.content ?? [])
226
+ .filter((p: any) => p.type === "text")
227
+ .map((p: any) => p.text as string)
228
+ .join("\n");
229
+ const durMs = startedAtTool != null ? now - startedAtTool : undefined;
230
+ let entry: ToolCallEntry | undefined;
231
+ for (let i = toolCalls.length - 1; i >= 0; i--) {
232
+ if (toolCalls[i]!.id === event.toolCallId) { entry = toolCalls[i]; break; }
233
+ }
234
+ if (!entry) {
235
+ for (let i = toolCalls.length - 1; i >= 0; i--) {
236
+ if (toolCalls[i]!.name === event.toolName && toolCalls[i]!.result === undefined) { entry = toolCalls[i]; break; }
237
+ }
238
+ }
239
+ if (entry) {
240
+ entry.result = resultText;
241
+ entry.isError = event.isError;
242
+ entry.durMs = durMs;
243
+ }
244
+ executionEvents.push({
245
+ type: "tool_end",
246
+ toolCallId: event.toolCallId,
247
+ result: resultText,
248
+ isError: event.isError,
249
+ durMs: durMs ?? 0,
250
+ timestamp: now,
251
+ });
252
+ emitUpdate();
253
+ return;
254
+ }
255
+
256
+ if (event.type === "message_update") {
257
+ const e = event.assistantMessageEvent;
258
+ if (e?.type === "text_delta" && e.delta) {
259
+ currentDelta += e.delta;
260
+ executionEvents.push({
261
+ type: "text_delta",
262
+ text: e.delta,
263
+ timestamp: now,
264
+ });
265
+ emitUpdate();
266
+ }
267
+ return;
268
+ }
269
+
270
+ if (event.type !== "message_end" || !event.message) return;
271
+ const msg = event.message;
272
+ if (msg.role !== "assistant") return;
273
+
274
+ usage.turns++;
275
+ const u = msg.usage;
276
+ if (u) {
277
+ usage.input += u.input ?? 0;
278
+ usage.output += u.output ?? 0;
279
+ usage.cost += u.cost?.total ?? 0;
280
+ }
281
+ if (msg.model) detectedModel = msg.model;
282
+
283
+ for (const part of msg.content ?? []) {
284
+ if (part.type === "text") {
285
+ lastOutput = part.text;
286
+ break;
287
+ }
288
+ }
289
+ currentDelta = "";
290
+
291
+ onUpdate?.({
292
+ content: [{ type: "text", text: lastOutput || "(running...)" }],
293
+ details: {
294
+ agentName: agent.name,
295
+ usage,
296
+ running: true,
297
+ elapsedMs: Date.now() - startedAt,
298
+ model: detectedModel ?? configuredModel,
299
+ toolCalls: [...toolCalls],
300
+ executionEvents: [...executionEvents],
301
+ } as unknown as SubagentDetails,
302
+ });
303
+ });
304
+
305
+ // Propagate depth to nested calls. `maxDepth` is per-agent and defaults to 0,
306
+ // so subagents cannot spawn subagents unless their frontmatter opts in.
307
+ const prevEnvDepth = process.env[DEPTH_ENV];
308
+ const prevEnvMaxDepth = process.env[MAX_DEPTH_ENV];
309
+ const prevDepth = _currentDepth;
310
+ const prevMaxDepth = _currentMaxDepth;
311
+ const maxDepth = Math.max(DEFAULT_MAX_DEPTH, agent.maxDepth ?? DEFAULT_MAX_DEPTH);
312
+ _currentDepth = depth + 1;
313
+ _currentMaxDepth = depth + maxDepth;
314
+ process.env[DEPTH_ENV] = String(_currentDepth);
315
+ process.env[MAX_DEPTH_ENV] = String(_currentMaxDepth);
316
+
317
+ let exitCode = 0;
318
+ let error: string | undefined;
319
+
320
+ try {
321
+ if (signal?.aborted) throw new Error("Aborted");
322
+
323
+ const onAbort = () => void session.abort();
324
+ signal?.addEventListener("abort", onAbort, { once: true });
325
+ try {
326
+ await session.prompt(task);
327
+ } finally {
328
+ signal?.removeEventListener("abort", onAbort);
329
+ }
330
+ } catch (e) {
331
+ exitCode = 1;
332
+ error = signal?.aborted ? "Aborted" : e instanceof Error ? e.message : String(e);
333
+ } finally {
334
+ done = true;
335
+ clearInterval(heartbeat);
336
+ unsubscribe();
337
+ session.dispose();
338
+ loaderLease.release();
339
+ if (prevEnvDepth === undefined) delete process.env[DEPTH_ENV];
340
+ else process.env[DEPTH_ENV] = prevEnvDepth;
341
+ if (prevEnvMaxDepth === undefined) delete process.env[MAX_DEPTH_ENV];
342
+ else process.env[MAX_DEPTH_ENV] = prevEnvMaxDepth;
343
+ _currentDepth = prevDepth;
344
+ _currentMaxDepth = prevMaxDepth;
345
+ }
346
+
347
+ return { output: lastOutput, exitCode, error, model: detectedModel, toolCalls, executionEvents, usage };
348
+ }
349
+
350
+ // ─── Concurrency helper ─────────────────────────────────────────────────────
351
+
352
+ export async function mapConcurrent<TIn, TOut>(
353
+ items: TIn[],
354
+ concurrency: number,
355
+ fn: (item: TIn, i: number) => Promise<TOut>,
356
+ ): Promise<TOut[]> {
357
+ if (!items.length) return [];
358
+ const limit = Math.max(1, Math.min(concurrency, items.length));
359
+ const results: TOut[] = new Array(items.length);
360
+ let next = 0;
361
+ await Promise.all(
362
+ Array.from({ length: limit }, async () => {
363
+ while (true) {
364
+ const i = next++;
365
+ if (i >= items.length) return;
366
+ results[i] = await fn(items[i]!, i);
367
+ }
368
+ }),
369
+ );
370
+ return results;
371
+ }
package/schemas.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Typebox parameter schema for the `subagent` tool.
3
+ */
4
+
5
+ import { Type } from "@sinclair/typebox";
6
+
7
+ const TaskItem = Type.Object({
8
+ agent: Type.String({ description: "Agent name" }),
9
+ task: Type.String({ description: "Task to delegate" }),
10
+ model: Type.Optional(Type.String({ description: "Model override (provider/model)" })),
11
+ cwd: Type.Optional(Type.String({ description: "Working directory" })),
12
+ count: Type.Optional(Type.Number({ description: "Repeat this task N times" })),
13
+ });
14
+
15
+ export const SubagentParams = Type.Object({
16
+ // Single mode
17
+ agent: Type.Optional(Type.String({ description: "Agent name (single mode)" })),
18
+ task: Type.Optional(Type.String({ description: "Task (single mode)" })),
19
+ model: Type.Optional(Type.String({ description: "Model override (single mode)" })),
20
+ cwd: Type.Optional(Type.String({ description: "Working directory" })),
21
+
22
+ // Parallel mode
23
+ tasks: Type.Optional(
24
+ Type.Array(TaskItem, {
25
+ description: "Array of {agent, task} for parallel execution. Use count to repeat one task.",
26
+ }),
27
+ ),
28
+ concurrency: Type.Optional(
29
+ Type.Number({ description: "Max parallel concurrency (default: 4)", default: 4 }),
30
+ ),
31
+
32
+ // Background
33
+ background: Type.Optional(Type.Boolean({ description: "Run in background, returns job ID immediately" })),
34
+ jobId: Type.Optional(Type.String({ description: "Job ID for poll/cancel" })),
35
+
36
+ // Management
37
+ action: Type.Optional(
38
+ Type.Union(
39
+ [
40
+ Type.Literal("list"),
41
+ Type.Literal("get"),
42
+ Type.Literal("status"),
43
+ Type.Literal("poll"),
44
+ Type.Literal("cancel"),
45
+ Type.Literal("detach"),
46
+ ],
47
+ {
48
+ description:
49
+ "'list'/'get' for agents, 'status' for bg jobs, 'poll'/'cancel' for a specific job, 'detach' to move a foreground job to background",
50
+ },
51
+ ),
52
+ ),
53
+ agentScope: Type.Optional(
54
+ Type.Union(
55
+ [Type.Literal("user"), Type.Literal("project"), Type.Literal("both")],
56
+ { description: "Agent scope filter", default: "both" },
57
+ ),
58
+ ),
59
+ });
package/types.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Shared runtime types for the subagent tool.
3
+ */
4
+
5
+ export interface ToolCallEntry {
6
+ id: string;
7
+ name: string;
8
+ argSummary: string;
9
+ result?: string;
10
+ isError?: boolean;
11
+ durMs?: number;
12
+ }
13
+
14
+ export type ExecutionEvent =
15
+ | { type: "tool_start"; toolCallId: string; toolName: string; argSummary: string; timestamp: number }
16
+ | { type: "text_delta"; text: string; timestamp: number }
17
+ | { type: "tool_end"; toolCallId: string; result: string; isError: boolean; durMs: number; timestamp: number };
18
+
19
+ export interface RunResult {
20
+ output: string;
21
+ exitCode: number;
22
+ error?: string;
23
+ model?: string;
24
+ toolCalls: ToolCallEntry[];
25
+ executionEvents?: ExecutionEvent[];
26
+ usage: { input: number; output: number; cost: number; turns: number };
27
+ }
28
+
29
+ export interface AgentRowStatus {
30
+ name: string;
31
+ taskSummary: string;
32
+ status: "pending" | "running" | "done" | "error";
33
+ durMs?: number;
34
+ toolCalls?: ToolCallEntry[];
35
+ responseText?: string;
36
+ }
37
+
38
+ export interface SubagentDetails {
39
+ mode?: "single" | "parallel";
40
+ agentName?: string;
41
+ task?: string;
42
+ parallelAgents?: AgentRowStatus[];
43
+ usage: RunResult["usage"];
44
+ running: boolean;
45
+ elapsedMs?: number;
46
+ model?: string;
47
+ backgroundJobId?: string;
48
+ toolCalls: ToolCallEntry[];
49
+ executionEvents?: ExecutionEvent[];
50
+ }
51
+
52
+ export type OnUpdate = (partial: {
53
+ content: [{ type: "text"; text: string }];
54
+ details: unknown;
55
+ }) => void;