@narumitw/pi-subagents 0.13.0 → 0.14.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/src/runner.ts ADDED
@@ -0,0 +1,496 @@
1
+ import { spawn } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
6
+ import type { Message } from "@earendil-works/pi-ai";
7
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
8
+ import type {
9
+ AgentConfig,
10
+ AgentScope,
11
+ AgentSource,
12
+ SubagentThinkingLevel,
13
+ } from "./agents.js";
14
+ import {
15
+ appendBounded,
16
+ DEFAULT_MAX_CONTEXT_BYTES,
17
+ DEFAULT_MAX_MESSAGES,
18
+ DEFAULT_MAX_OUTPUT_BYTES,
19
+ DEFAULT_MAX_STDERR_BYTES,
20
+ truncateUtf8,
21
+ } from "./limits.js";
22
+ import { JsonLineDecoder } from "./protocol.js";
23
+
24
+ export const KILL_GRACE_MS = 5000;
25
+
26
+ export interface UsageStats {
27
+ input: number;
28
+ output: number;
29
+ cacheRead: number;
30
+ cacheWrite: number;
31
+ cost: number;
32
+ contextTokens: number;
33
+ turns: number;
34
+ }
35
+
36
+ export interface SingleResult {
37
+ agent: string;
38
+ agentSource: AgentSource | "unknown";
39
+ task: string;
40
+ exitCode: number;
41
+ messages: Message[];
42
+ stderr: string;
43
+ usage: UsageStats;
44
+ model?: string;
45
+ thinkingLevel?: SubagentThinkingLevel;
46
+ stopReason?: string;
47
+ errorMessage?: string;
48
+ step?: number;
49
+ finalOutput?: string;
50
+ timedOut?: boolean;
51
+ timeoutMs?: number;
52
+ aborted?: boolean;
53
+ truncated?: boolean;
54
+ malformedEvents?: number;
55
+ policy?: {
56
+ inherited: string[];
57
+ overridden: string[];
58
+ unsupported: string[];
59
+ };
60
+ }
61
+
62
+ export interface SubagentDetails {
63
+ mode: "single" | "parallel" | "chain";
64
+ agentScope: AgentScope;
65
+ projectAgentsDir: string | null;
66
+ results: SingleResult[];
67
+ aggregator?: SingleResult;
68
+ isError?: boolean;
69
+ }
70
+
71
+ function getFinalOutput(messages: Message[]): string {
72
+ for (let i = messages.length - 1; i >= 0; i--) {
73
+ const msg = messages[i];
74
+ if (msg.role === "assistant") {
75
+ for (const part of msg.content) {
76
+ if (part.type === "text") return part.text;
77
+ }
78
+ }
79
+ }
80
+ return "";
81
+ }
82
+
83
+ export function getResultFinalOutput(result: SingleResult): string {
84
+ return result.finalOutput ?? getFinalOutput(result.messages);
85
+ }
86
+
87
+ function boundMessageText(message: Message, maxBytes: number): { message: Message; bytes: number; truncated: boolean } {
88
+ const serializedBytes = Buffer.byteLength(JSON.stringify(message), "utf8");
89
+ if (serializedBytes <= maxBytes) return { message, bytes: serializedBytes, truncated: false };
90
+ if (!Array.isArray(message.content)) return { message, bytes: Math.min(serializedBytes, maxBytes), truncated: true };
91
+ let remaining = maxBytes;
92
+ const content = message.content
93
+ .filter((part) => part.type === "text")
94
+ .map((part) => {
95
+ const bounded = truncateUtf8(part.text, remaining);
96
+ remaining = Math.max(0, remaining - Buffer.byteLength(bounded.text, "utf8"));
97
+ return { ...part, text: bounded.text };
98
+ });
99
+ const boundedMessage = { ...message, content } as Message;
100
+ return {
101
+ message: boundedMessage,
102
+ bytes: Math.min(Buffer.byteLength(JSON.stringify(boundedMessage), "utf8"), maxBytes),
103
+ truncated: true,
104
+ };
105
+ }
106
+
107
+ export function buildFanInContext(results: SingleResult[], maxBytes = DEFAULT_MAX_CONTEXT_BYTES): string {
108
+ const text = results
109
+ .map((result, index) => {
110
+ const status = result.exitCode === 0 ? "completed" : result.exitCode === -1 ? "running" : "failed";
111
+ const output = getResultFinalOutput(result);
112
+ const error = result.errorMessage || result.stderr.trim();
113
+ return [
114
+ `## Result ${index + 1}: ${result.agent} (${status})`,
115
+ `Task: ${result.task}`,
116
+ output ? `Output:\n${output}` : error ? `Error:\n${error}` : "Output: (no output)",
117
+ ].join("\n\n");
118
+ })
119
+ .join("\n\n---\n\n");
120
+ return truncateUtf8(text, maxBytes).text;
121
+ }
122
+
123
+ export async function mapWithConcurrencyLimit<TIn, TOut>(
124
+ items: TIn[],
125
+ concurrency: number,
126
+ fn: (item: TIn, index: number) => Promise<TOut>,
127
+ signal?: AbortSignal,
128
+ onSkipped?: (item: TIn, index: number) => TOut,
129
+ ): Promise<TOut[]> {
130
+ if (items.length === 0) return [];
131
+ const limit = Math.max(1, Math.min(concurrency, items.length));
132
+ const results: TOut[] = new Array(items.length);
133
+ let nextIndex = 0;
134
+ const workers = new Array(limit).fill(null).map(async () => {
135
+ while (true) {
136
+ const current = nextIndex++;
137
+ if (current >= items.length) return;
138
+ if (signal?.aborted && onSkipped) {
139
+ results[current] = onSkipped(items[current], current);
140
+ continue;
141
+ }
142
+ results[current] = await fn(items[current], current);
143
+ }
144
+ });
145
+ await Promise.all(workers);
146
+ return results;
147
+ }
148
+
149
+ async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
150
+ const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
151
+ const safeName = agentName.replace(/[^\w.-]+/g, "_");
152
+ const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
153
+ await withFileMutationQueue(filePath, async () => {
154
+ await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
155
+ });
156
+ return { dir: tmpDir, filePath };
157
+ }
158
+
159
+ export function buildPiArgs(options: {
160
+ model?: string;
161
+ thinkingLevel?: SubagentThinkingLevel;
162
+ tools?: string[];
163
+ systemPromptPath?: string;
164
+ task: string;
165
+ }): string[] {
166
+ const args: string[] = ["--mode", "json", "-p", "--no-session"];
167
+ if (options.model) args.push("--model", options.model);
168
+ if (options.thinkingLevel) args.push("--thinking", options.thinkingLevel);
169
+ if (Array.isArray(options.tools)) {
170
+ if (options.tools.length > 0) args.push("--tools", options.tools.join(","));
171
+ else args.push("--no-tools");
172
+ }
173
+ if (options.systemPromptPath) args.push("--append-system-prompt", options.systemPromptPath);
174
+ args.push(`Task: ${options.task}`);
175
+ return args;
176
+ }
177
+
178
+ function getPiInvocation(args: string[]): { command: string; args: string[] } {
179
+ const currentScript = process.argv[1];
180
+ const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
181
+ if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
182
+ return { command: process.execPath, args: [currentScript, ...args] };
183
+ }
184
+
185
+ const execName = path.basename(process.execPath).toLowerCase();
186
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
187
+ if (!isGenericRuntime) {
188
+ return { command: process.execPath, args };
189
+ }
190
+
191
+ return { command: "pi", args };
192
+ }
193
+
194
+ function signalProcess(proc: ReturnType<typeof spawn>, signal: NodeJS.Signals): void {
195
+ if (process.platform !== "win32" && proc.pid) {
196
+ try {
197
+ process.kill(-proc.pid, signal);
198
+ return;
199
+ } catch {
200
+ // Fall back to signaling the immediate child when process-group signaling is unavailable.
201
+ }
202
+ }
203
+ try {
204
+ proc.kill(signal);
205
+ } catch {
206
+ // The process may already have exited.
207
+ }
208
+ }
209
+
210
+ export function terminateProcess(proc: ReturnType<typeof spawn>, graceMs = KILL_GRACE_MS): () => void {
211
+ let closed = proc.exitCode !== null || proc.signalCode !== null;
212
+ const onClose = () => {
213
+ closed = true;
214
+ };
215
+ proc.once("close", onClose);
216
+ if (!closed) signalProcess(proc, "SIGTERM");
217
+ const escalation = setTimeout(() => {
218
+ if (!closed) signalProcess(proc, "SIGKILL");
219
+ }, graceMs);
220
+ escalation.unref();
221
+ return () => {
222
+ clearTimeout(escalation);
223
+ proc.off("close", onClose);
224
+ };
225
+ }
226
+
227
+ export type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
228
+
229
+ export async function runSingleAgent(
230
+ defaultCwd: string,
231
+ agents: AgentConfig[],
232
+ agentName: string,
233
+ task: string,
234
+ cwd: string | undefined,
235
+ step: number | undefined,
236
+ signal: AbortSignal | undefined,
237
+ thinkingLevel: SubagentThinkingLevel | undefined,
238
+ timeoutMs: number,
239
+ onUpdate: OnUpdateCallback | undefined,
240
+ makeDetails: (results: SingleResult[]) => SubagentDetails,
241
+ invocationOverride?: { command: string; argsPrefix?: string[] },
242
+ ): Promise<SingleResult> {
243
+ const agent = agents.find((a) => a.name === agentName);
244
+
245
+ if (!agent) {
246
+ const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
247
+ return {
248
+ agent: agentName,
249
+ agentSource: "unknown",
250
+ task,
251
+ exitCode: 1,
252
+ messages: [],
253
+ stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
254
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
255
+ thinkingLevel,
256
+ step,
257
+ finalOutput: "",
258
+ };
259
+ }
260
+
261
+ let tmpPromptDir: string | null = null;
262
+ let tmpPromptPath: string | null = null;
263
+
264
+ const currentResult: SingleResult = {
265
+ agent: agentName,
266
+ agentSource: agent.source,
267
+ task,
268
+ exitCode: 0,
269
+ messages: [],
270
+ stderr: "",
271
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
272
+ model: agent.model ?? undefined,
273
+ thinkingLevel,
274
+ step,
275
+ timeoutMs,
276
+ };
277
+
278
+ const emitUpdate = () => {
279
+ currentResult.finalOutput = getFinalOutput(currentResult.messages);
280
+ if (onUpdate) {
281
+ onUpdate({
282
+ content: [{ type: "text", text: currentResult.finalOutput || "(running...)" }],
283
+ details: makeDetails([currentResult]),
284
+ });
285
+ }
286
+ };
287
+
288
+ try {
289
+ const effectiveCwd = cwd ?? defaultCwd;
290
+ try {
291
+ if (!fs.statSync(effectiveCwd).isDirectory()) throw new Error("not a directory");
292
+ } catch (error) {
293
+ currentResult.exitCode = 1;
294
+ currentResult.stopReason = "error";
295
+ const reason = error instanceof Error ? error.message : String(error);
296
+ currentResult.errorMessage = `Invalid subagent cwd: ${effectiveCwd} (${reason})`;
297
+ currentResult.stderr = currentResult.errorMessage;
298
+ return currentResult;
299
+ }
300
+
301
+ if (signal?.aborted) {
302
+ currentResult.exitCode = 130;
303
+ currentResult.aborted = true;
304
+ currentResult.stopReason = "aborted";
305
+ currentResult.errorMessage = "Subagent was aborted before start";
306
+ return currentResult;
307
+ }
308
+
309
+ if (agent.systemPrompt.trim()) {
310
+ const tmp = await writePromptToTempFile(agent.name, agent.systemPrompt);
311
+ tmpPromptDir = tmp.dir;
312
+ tmpPromptPath = tmp.filePath;
313
+ }
314
+
315
+ const args = buildPiArgs({
316
+ model: agent.model,
317
+ thinkingLevel,
318
+ tools: agent.tools,
319
+ systemPromptPath: tmpPromptPath ?? undefined,
320
+ task,
321
+ });
322
+ let wasAborted = false;
323
+ let timedOut = false;
324
+
325
+ const exitCode = await new Promise<number>((resolve) => {
326
+ const invocation = invocationOverride
327
+ ? {
328
+ command: invocationOverride.command,
329
+ args: [...(invocationOverride.argsPrefix ?? []), ...args],
330
+ }
331
+ : getPiInvocation(args);
332
+ let settled = false;
333
+ let cleanupTermination: (() => void) | undefined;
334
+ let timeout: NodeJS.Timeout | undefined;
335
+ let abortHandler: (() => void) | undefined;
336
+ const finish = (code: number) => {
337
+ if (settled) return;
338
+ settled = true;
339
+ if (timeout) clearTimeout(timeout);
340
+ cleanupTermination?.();
341
+ if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
342
+ resolve(code);
343
+ };
344
+ let proc: ReturnType<typeof spawn>;
345
+ try {
346
+ proc = spawn(invocation.command, invocation.args, {
347
+ cwd: effectiveCwd,
348
+ detached: process.platform !== "win32",
349
+ shell: false,
350
+ stdio: ["ignore", "pipe", "pipe"],
351
+ env: {
352
+ ...process.env,
353
+ PI_SUBAGENT_DEPTH: String(
354
+ (Number.parseInt(process.env.PI_SUBAGENT_DEPTH ?? "0", 10) || 0) + 1,
355
+ ),
356
+ },
357
+ });
358
+ } catch (error) {
359
+ currentResult.errorMessage = error instanceof Error ? error.message : String(error);
360
+ currentResult.stderr = currentResult.errorMessage;
361
+ finish(1);
362
+ return;
363
+ }
364
+
365
+ let capturedMessageBytes = 0;
366
+ const addMessage = (msg: Message) => {
367
+ if (currentResult.messages.length >= DEFAULT_MAX_MESSAGES) {
368
+ currentResult.truncated = true;
369
+ return;
370
+ }
371
+ const remaining = Math.max(0, DEFAULT_MAX_OUTPUT_BYTES - capturedMessageBytes);
372
+ const boundedMessage = boundMessageText(msg, remaining);
373
+ capturedMessageBytes += boundedMessage.bytes;
374
+ currentResult.truncated ||= boundedMessage.truncated;
375
+ currentResult.messages.push(boundedMessage.message);
376
+ };
377
+ const processEvent = (raw: unknown) => {
378
+ if (!raw || typeof raw !== "object") return;
379
+ const event = raw as { type?: string; message?: Message };
380
+ if (event.type === "message_end" && event.message) {
381
+ const msg = event.message;
382
+ addMessage(msg);
383
+ if (msg.role === "assistant") {
384
+ currentResult.usage.turns++;
385
+ const usage = msg.usage;
386
+ if (usage) {
387
+ currentResult.usage.input += usage.input || 0;
388
+ currentResult.usage.output += usage.output || 0;
389
+ currentResult.usage.cacheRead += usage.cacheRead || 0;
390
+ currentResult.usage.cacheWrite += usage.cacheWrite || 0;
391
+ currentResult.usage.cost += usage.cost?.total || 0;
392
+ currentResult.usage.contextTokens = usage.totalTokens || 0;
393
+ }
394
+ if (!currentResult.model && msg.model) currentResult.model = msg.model;
395
+ if (msg.stopReason) currentResult.stopReason = msg.stopReason;
396
+ if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage;
397
+ }
398
+ emitUpdate();
399
+ } else if (event.type === "tool_result_end" && event.message) {
400
+ addMessage(event.message);
401
+ emitUpdate();
402
+ }
403
+ };
404
+ const decoder = new JsonLineDecoder({
405
+ onValue: processEvent,
406
+ onMalformed: () => {
407
+ currentResult.malformedEvents = (currentResult.malformedEvents ?? 0) + 1;
408
+ },
409
+ onOversized: () => {
410
+ currentResult.truncated = true;
411
+ },
412
+ });
413
+
414
+ timeout = setTimeout(() => {
415
+ timedOut = true;
416
+ currentResult.timedOut = true;
417
+ currentResult.stopReason = "timeout";
418
+ currentResult.errorMessage = `Subagent timed out after ${timeoutMs}ms`;
419
+ const bounded = appendBounded(
420
+ currentResult.stderr,
421
+ `\nSubagent timed out after ${timeoutMs}ms.`,
422
+ DEFAULT_MAX_STDERR_BYTES,
423
+ );
424
+ currentResult.stderr = bounded.text;
425
+ currentResult.truncated ||= bounded.truncated;
426
+ emitUpdate();
427
+ cleanupTermination = terminateProcess(proc);
428
+ }, timeoutMs);
429
+ timeout.unref();
430
+
431
+ proc.stdout?.on("data", (data) => decoder.push(data));
432
+ proc.stderr?.on("data", (data) => {
433
+ const bounded = appendBounded(currentResult.stderr, data.toString(), DEFAULT_MAX_STDERR_BYTES);
434
+ currentResult.stderr = bounded.text;
435
+ currentResult.truncated ||= bounded.truncated;
436
+ });
437
+ proc.on("close", (code) => {
438
+ decoder.finish();
439
+ finish(timedOut ? 124 : wasAborted ? 130 : (code ?? 0));
440
+ });
441
+ proc.on("error", (error) => {
442
+ currentResult.errorMessage = error.message;
443
+ const bounded = appendBounded(
444
+ currentResult.stderr,
445
+ `${currentResult.stderr ? "\n" : ""}${error.message}`,
446
+ DEFAULT_MAX_STDERR_BYTES,
447
+ );
448
+ currentResult.stderr = bounded.text;
449
+ currentResult.truncated ||= bounded.truncated;
450
+ finish(1);
451
+ });
452
+
453
+ if (signal) {
454
+ abortHandler = () => {
455
+ if (timedOut || settled) return;
456
+ wasAborted = true;
457
+ currentResult.aborted = true;
458
+ currentResult.stopReason = "aborted";
459
+ currentResult.errorMessage = "Subagent was aborted";
460
+ cleanupTermination = terminateProcess(proc);
461
+ };
462
+ if (signal.aborted) abortHandler();
463
+ else signal.addEventListener("abort", abortHandler, { once: true });
464
+ }
465
+ });
466
+
467
+ currentResult.exitCode = exitCode;
468
+ const final = truncateUtf8(getFinalOutput(currentResult.messages), DEFAULT_MAX_OUTPUT_BYTES);
469
+ currentResult.finalOutput = final.text;
470
+ currentResult.truncated ||= final.truncated;
471
+ currentResult.policy = {
472
+ inherited: ["environment"],
473
+ overridden: [
474
+ "cwd",
475
+ ...(agent.model ? ["model"] : []),
476
+ ...(thinkingLevel ? ["thinkingLevel"] : []),
477
+ ...(agent.tools ? ["tools"] : []),
478
+ ],
479
+ unsupported: ["approvalPolicy", "sandboxProfile", "providerHeaders"],
480
+ };
481
+ return currentResult;
482
+ } finally {
483
+ if (tmpPromptPath)
484
+ try {
485
+ fs.unlinkSync(tmpPromptPath);
486
+ } catch {
487
+ /* ignore */
488
+ }
489
+ if (tmpPromptDir)
490
+ try {
491
+ fs.rmdirSync(tmpPromptDir);
492
+ } catch {
493
+ /* ignore */
494
+ }
495
+ }
496
+ }
@@ -0,0 +1,166 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import {
5
+ type AgentConfig,
6
+ isThinkingLevel,
7
+ type SubagentAgentConfig,
8
+ type SubagentSettings,
9
+ type SubagentThinkingLevel,
10
+ } from "./agents.js";
11
+
12
+ export function hasOwn(obj: object, key: PropertyKey): boolean {
13
+ return Object.hasOwn(obj, key);
14
+ }
15
+
16
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+
20
+ function isStringArray(value: unknown): value is string[] {
21
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
22
+ }
23
+
24
+ function isPositiveNumber(value: unknown): value is number {
25
+ return typeof value === "number" && Number.isFinite(value) && value >= 1;
26
+ }
27
+
28
+ function isPositiveInteger(value: unknown): value is number {
29
+ return isPositiveNumber(value) && Number.isSafeInteger(value);
30
+ }
31
+
32
+ function isNonNegativeInteger(value: unknown): value is number {
33
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
34
+ }
35
+
36
+ export function normalizeAgentSettings(value: unknown): SubagentAgentConfig | undefined {
37
+ if (!isPlainObject(value)) return undefined;
38
+
39
+ const config: SubagentAgentConfig = {};
40
+ let hasKnownField = false;
41
+
42
+ if (hasOwn(value, "tools")) {
43
+ if (!isStringArray(value.tools)) return undefined;
44
+ config.tools = value.tools;
45
+ hasKnownField = true;
46
+ }
47
+
48
+ if (hasOwn(value, "model")) {
49
+ if (value.model !== null && typeof value.model !== "string") return undefined;
50
+ config.model = value.model;
51
+ hasKnownField = true;
52
+ }
53
+
54
+ if (hasOwn(value, "thinkingLevel")) {
55
+ if (value.thinkingLevel !== null && !isThinkingLevel(value.thinkingLevel)) return undefined;
56
+ config.thinkingLevel = value.thinkingLevel;
57
+ hasKnownField = true;
58
+ }
59
+
60
+ if (hasOwn(value, "timeoutMs")) {
61
+ if (value.timeoutMs !== null && !isPositiveNumber(value.timeoutMs)) return undefined;
62
+ config.timeoutMs = value.timeoutMs;
63
+ hasKnownField = true;
64
+ }
65
+
66
+ return hasKnownField ? config : undefined;
67
+ }
68
+
69
+ export function normalizeSubagentSettings(value: unknown): SubagentSettings | undefined {
70
+ if (!isPlainObject(value)) return undefined;
71
+ const settings: SubagentSettings = {};
72
+ if (hasOwn(value, "agents")) {
73
+ if (!isPlainObject(value.agents)) return undefined;
74
+ const agents: Record<string, SubagentAgentConfig> = {};
75
+ for (const [name, rawConfig] of Object.entries(value.agents)) {
76
+ const config = normalizeAgentSettings(rawConfig);
77
+ if (config) agents[name] = config;
78
+ }
79
+ if (Object.keys(agents).length > 0) settings.agents = agents;
80
+ }
81
+ if (hasOwn(value, "stateful")) {
82
+ if (!isPlainObject(value.stateful)) return undefined;
83
+ const runtime: NonNullable<SubagentSettings["stateful"]> = {};
84
+ if (hasOwn(value.stateful, "transport")) {
85
+ if (value.stateful.transport !== "subprocess" && value.stateful.transport !== "in-process") {
86
+ return undefined;
87
+ }
88
+ runtime.transport = value.stateful.transport;
89
+ }
90
+ for (const key of [
91
+ "maxAgents",
92
+ "maxActiveTurns",
93
+ "maxChildrenPerAgent",
94
+ "maxMailboxMessages",
95
+ "maxMailboxMessageBytes",
96
+ "idleTtlMs",
97
+ "maxStoredAgents",
98
+ ] as const) {
99
+ if (hasOwn(value.stateful, key)) {
100
+ if (!isPositiveInteger(value.stateful[key])) return undefined;
101
+ runtime[key] = value.stateful[key];
102
+ }
103
+ }
104
+ if (hasOwn(value.stateful, "maxDepth")) {
105
+ if (!isNonNegativeInteger(value.stateful.maxDepth)) return undefined;
106
+ runtime.maxDepth = value.stateful.maxDepth;
107
+ }
108
+ if (hasOwn(value.stateful, "retentionDays")) {
109
+ if (!isPositiveNumber(value.stateful.retentionDays)) return undefined;
110
+ runtime.retentionDays = value.stateful.retentionDays;
111
+ }
112
+ if (hasOwn(value.stateful, "enabled")) {
113
+ if (typeof value.stateful.enabled !== "boolean") return undefined;
114
+ runtime.enabled = value.stateful.enabled;
115
+ }
116
+ settings.stateful = runtime;
117
+ }
118
+ return settings;
119
+ }
120
+
121
+ export function readSubagentSettings(): SubagentSettings | undefined {
122
+ const configPath = path.join(getAgentDir(), "pi-subagents-config.json");
123
+ if (!fs.existsSync(configPath)) return undefined;
124
+ try {
125
+ return normalizeSubagentSettings(JSON.parse(fs.readFileSync(configPath, "utf-8")));
126
+ } catch {
127
+ return undefined;
128
+ }
129
+ }
130
+
131
+ export function saveSubagentConfig(settings: SubagentSettings): void {
132
+ const agentDir = getAgentDir();
133
+ fs.mkdirSync(agentDir, { recursive: true });
134
+
135
+ const configPath = path.join(agentDir, "pi-subagents-config.json");
136
+ fs.writeFileSync(configPath, `${JSON.stringify(settings, null, "\t")}\n`, "utf-8");
137
+ }
138
+
139
+ export function uniqueToolNames(tools: string[]): string[] {
140
+ return [...new Set(tools)];
141
+ }
142
+
143
+ export function sameToolSet(left: string[], right: string[]): boolean {
144
+ const leftSet = new Set(left);
145
+ const rightSet = new Set(right);
146
+ if (leftSet.size !== rightSet.size) return false;
147
+ return [...leftSet].every((tool) => rightSet.has(tool));
148
+ }
149
+
150
+ export function resolveSubagentThinkingLevel(
151
+ agents: readonly Pick<AgentConfig, "name" | "thinkingLevel">[],
152
+ agentName: string,
153
+ topLevelThinkingLevel?: SubagentThinkingLevel,
154
+ localThinkingLevel?: SubagentThinkingLevel,
155
+ ): SubagentThinkingLevel | undefined {
156
+ return localThinkingLevel ?? topLevelThinkingLevel ?? agents.find((agent) => agent.name === agentName)?.thinkingLevel;
157
+ }
158
+
159
+ export function hasAnyAgentOverride(config: SubagentAgentConfig): boolean {
160
+ return (
161
+ hasOwn(config, "tools") ||
162
+ hasOwn(config, "model") ||
163
+ hasOwn(config, "thinkingLevel") ||
164
+ hasOwn(config, "timeoutMs")
165
+ );
166
+ }
@@ -0,0 +1,43 @@
1
+ import type { AgentConfig } from "./agents.js";
2
+ import { resolveDefaultSubagentTimeoutMs } from "./execution.js";
3
+ import { DEFAULT_MAX_CONTEXT_BYTES, truncateUtf8 } from "./limits.js";
4
+ import { redactPrivateText } from "./context.js";
5
+ import type { ManagedAgent } from "./registry.js";
6
+
7
+ export function buildStatefulTurnPrompt(
8
+ record: Pick<
9
+ ManagedAgent,
10
+ "context" | "history" | "mailbox" | "currentMailboxMessageIds"
11
+ >,
12
+ task: string,
13
+ maxBytes = DEFAULT_MAX_CONTEXT_BYTES,
14
+ ): { text: string; truncated: boolean } {
15
+ const previous = record.history
16
+ .map((turn) => {
17
+ const redactedTask = redactPrivateText(turn.task);
18
+ const redactedOutput = redactPrivateText(turn.output);
19
+ return `Task: ${redactedTask}\nOutput: ${redactedOutput}`;
20
+ })
21
+ .join("\n\n");
22
+ const currentMessageIds = new Set(record.currentMailboxMessageIds ?? []);
23
+ const messages = record.mailbox
24
+ .filter((message) => currentMessageIds.has(message.id))
25
+ .slice(-20)
26
+ .map((message) => `From ${message.senderId}: ${redactPrivateText(message.content)}`)
27
+ .join("\n");
28
+ const context = [
29
+ `Current task:\n${redactPrivateText(task)}`,
30
+ messages ? `Mailbox messages:\n${messages}` : "",
31
+ previous ? `Prior subagent turns:\n${previous}` : "",
32
+ record.context ? `Parent context:\n${redactPrivateText(record.context)}` : "",
33
+ ]
34
+ .filter(Boolean)
35
+ .join("\n\n---\n\n");
36
+ return truncateUtf8(context, maxBytes);
37
+ }
38
+
39
+ export function resolveStatefulTurnTimeout(
40
+ agent: Pick<AgentConfig, "timeoutMs"> | undefined,
41
+ ): number {
42
+ return agent?.timeoutMs ?? resolveDefaultSubagentTimeoutMs();
43
+ }