@narumitw/pi-subagents 0.13.1 → 0.14.1

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 CHANGED
@@ -11,8 +11,17 @@ import type {
11
11
  AgentSource,
12
12
  SubagentThinkingLevel,
13
13
  } from "./agents.js";
14
-
15
- const KILL_GRACE_MS = 5000;
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;
16
25
 
17
26
  export interface UsageStats {
18
27
  input: number;
@@ -40,6 +49,14 @@ export interface SingleResult {
40
49
  finalOutput?: string;
41
50
  timedOut?: boolean;
42
51
  timeoutMs?: number;
52
+ aborted?: boolean;
53
+ truncated?: boolean;
54
+ malformedEvents?: number;
55
+ policy?: {
56
+ inherited: string[];
57
+ overridden: string[];
58
+ unsupported: string[];
59
+ };
43
60
  }
44
61
 
45
62
  export interface SubagentDetails {
@@ -48,6 +65,7 @@ export interface SubagentDetails {
48
65
  projectAgentsDir: string | null;
49
66
  results: SingleResult[];
50
67
  aggregator?: SingleResult;
68
+ isError?: boolean;
51
69
  }
52
70
 
53
71
  function getFinalOutput(messages: Message[]): string {
@@ -66,8 +84,28 @@ export function getResultFinalOutput(result: SingleResult): string {
66
84
  return result.finalOutput ?? getFinalOutput(result.messages);
67
85
  }
68
86
 
69
- export function buildFanInContext(results: SingleResult[]): string {
70
- return results
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
71
109
  .map((result, index) => {
72
110
  const status = result.exitCode === 0 ? "completed" : result.exitCode === -1 ? "running" : "failed";
73
111
  const output = getResultFinalOutput(result);
@@ -79,12 +117,15 @@ export function buildFanInContext(results: SingleResult[]): string {
79
117
  ].join("\n\n");
80
118
  })
81
119
  .join("\n\n---\n\n");
120
+ return truncateUtf8(text, maxBytes).text;
82
121
  }
83
122
 
84
123
  export async function mapWithConcurrencyLimit<TIn, TOut>(
85
124
  items: TIn[],
86
125
  concurrency: number,
87
126
  fn: (item: TIn, index: number) => Promise<TOut>,
127
+ signal?: AbortSignal,
128
+ onSkipped?: (item: TIn, index: number) => TOut,
88
129
  ): Promise<TOut[]> {
89
130
  if (items.length === 0) return [];
90
131
  const limit = Math.max(1, Math.min(concurrency, items.length));
@@ -94,6 +135,10 @@ export async function mapWithConcurrencyLimit<TIn, TOut>(
94
135
  while (true) {
95
136
  const current = nextIndex++;
96
137
  if (current >= items.length) return;
138
+ if (signal?.aborted && onSkipped) {
139
+ results[current] = onSkipped(items[current], current);
140
+ continue;
141
+ }
97
142
  results[current] = await fn(items[current], current);
98
143
  }
99
144
  });
@@ -146,30 +191,37 @@ function getPiInvocation(args: string[]): { command: string; args: string[] } {
146
191
  return { command: "pi", args };
147
192
  }
148
193
 
149
- function terminateProcess(proc: ReturnType<typeof spawn>) {
150
- if (proc.killed) return;
194
+ function signalProcess(proc: ReturnType<typeof spawn>, signal: NodeJS.Signals): void {
151
195
  if (process.platform !== "win32" && proc.pid) {
152
196
  try {
153
- process.kill(-proc.pid, "SIGTERM");
197
+ process.kill(-proc.pid, signal);
198
+ return;
154
199
  } catch {
155
- proc.kill("SIGTERM");
200
+ // Fall back to signaling the immediate child when process-group signaling is unavailable.
156
201
  }
157
- } else {
158
- proc.kill("SIGTERM");
159
202
  }
203
+ try {
204
+ proc.kill(signal);
205
+ } catch {
206
+ // The process may already have exited.
207
+ }
208
+ }
160
209
 
161
- setTimeout(() => {
162
- if (proc.killed) return;
163
- if (process.platform !== "win32" && proc.pid) {
164
- try {
165
- process.kill(-proc.pid, "SIGKILL");
166
- } catch {
167
- proc.kill("SIGKILL");
168
- }
169
- } else {
170
- proc.kill("SIGKILL");
171
- }
172
- }, KILL_GRACE_MS).unref();
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
+ };
173
225
  }
174
226
 
175
227
  export type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
@@ -186,6 +238,7 @@ export async function runSingleAgent(
186
238
  timeoutMs: number,
187
239
  onUpdate: OnUpdateCallback | undefined,
188
240
  makeDetails: (results: SingleResult[]) => SubagentDetails,
241
+ invocationOverride?: { command: string; argsPrefix?: string[] },
189
242
  ): Promise<SingleResult> {
190
243
  const agent = agents.find((a) => a.name === agentName);
191
244
 
@@ -233,6 +286,26 @@ export async function runSingleAgent(
233
286
  };
234
287
 
235
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
+
236
309
  if (agent.systemPrompt.trim()) {
237
310
  const tmp = await writePromptToTempFile(agent.name, agent.systemPrompt);
238
311
  tmpPromptDir = tmp.dir;
@@ -250,45 +323,63 @@ export async function runSingleAgent(
250
323
  let timedOut = false;
251
324
 
252
325
  const exitCode = await new Promise<number>((resolve) => {
253
- const invocation = getPiInvocation(args);
326
+ const invocation = invocationOverride
327
+ ? {
328
+ command: invocationOverride.command,
329
+ args: [...(invocationOverride.argsPrefix ?? []), ...args],
330
+ }
331
+ : getPiInvocation(args);
254
332
  let settled = false;
333
+ let cleanupTermination: (() => void) | undefined;
334
+ let timeout: NodeJS.Timeout | undefined;
335
+ let abortHandler: (() => void) | undefined;
255
336
  const finish = (code: number) => {
256
337
  if (settled) return;
257
338
  settled = true;
258
- clearTimeout(timeout);
339
+ if (timeout) clearTimeout(timeout);
340
+ cleanupTermination?.();
341
+ if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
259
342
  resolve(code);
260
343
  };
261
- const proc = spawn(invocation.command, invocation.args, {
262
- cwd: cwd ?? defaultCwd,
263
- detached: process.platform !== "win32",
264
- shell: false,
265
- stdio: ["ignore", "pipe", "pipe"],
266
- });
267
- let buffer = "";
268
- const timeout = setTimeout(() => {
269
- timedOut = true;
270
- currentResult.timedOut = true;
271
- currentResult.stopReason = "timeout";
272
- currentResult.errorMessage = `Subagent timed out after ${timeoutMs}ms`;
273
- currentResult.stderr += `${currentResult.stderr ? "\n" : ""}Subagent timed out after ${timeoutMs}ms.`;
274
- emitUpdate();
275
- terminateProcess(proc);
276
- }, timeoutMs);
277
- timeout.unref();
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
+ }
278
364
 
279
- const processLine = (line: string) => {
280
- if (!line.trim()) return;
281
- let event: any;
282
- try {
283
- event = JSON.parse(line);
284
- } catch {
365
+ let capturedMessageBytes = 0;
366
+ const addMessage = (msg: Message) => {
367
+ if (currentResult.messages.length >= DEFAULT_MAX_MESSAGES) {
368
+ currentResult.truncated = true;
285
369
  return;
286
370
  }
287
-
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 };
288
380
  if (event.type === "message_end" && event.message) {
289
- const msg = event.message as Message;
290
- currentResult.messages.push(msg);
291
-
381
+ const msg = event.message;
382
+ addMessage(msg);
292
383
  if (msg.role === "assistant") {
293
384
  currentResult.usage.turns++;
294
385
  const usage = msg.usage;
@@ -305,51 +396,88 @@ export async function runSingleAgent(
305
396
  if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage;
306
397
  }
307
398
  emitUpdate();
308
- }
309
-
310
- if (event.type === "tool_result_end" && event.message) {
311
- currentResult.messages.push(event.message as Message);
399
+ } else if (event.type === "tool_result_end" && event.message) {
400
+ addMessage(event.message);
312
401
  emitUpdate();
313
402
  }
314
403
  };
315
-
316
- proc.stdout.on("data", (data) => {
317
- buffer += data.toString();
318
- const lines = buffer.split("\n");
319
- buffer = lines.pop() || "";
320
- for (const line of lines) processLine(line);
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
+ },
321
412
  });
322
413
 
323
- proc.stderr.on("data", (data) => {
324
- currentResult.stderr += data.toString();
325
- });
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();
326
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
+ });
327
437
  proc.on("close", (code) => {
328
- if (buffer.trim()) processLine(buffer);
329
- finish(timedOut ? 124 : (code ?? 0));
438
+ decoder.finish();
439
+ finish(timedOut ? 124 : wasAborted ? 130 : (code ?? 0));
330
440
  });
331
-
332
441
  proc.on("error", (error) => {
333
442
  currentResult.errorMessage = error.message;
334
- currentResult.stderr += `${currentResult.stderr ? "\n" : ""}${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;
335
450
  finish(1);
336
451
  });
337
452
 
338
453
  if (signal) {
339
- const killProc = () => {
454
+ abortHandler = () => {
455
+ if (timedOut || settled) return;
340
456
  wasAborted = true;
457
+ currentResult.aborted = true;
341
458
  currentResult.stopReason = "aborted";
342
459
  currentResult.errorMessage = "Subagent was aborted";
343
- terminateProcess(proc);
460
+ cleanupTermination = terminateProcess(proc);
344
461
  };
345
- if (signal.aborted) killProc();
346
- else signal.addEventListener("abort", killProc, { once: true });
462
+ if (signal.aborted) abortHandler();
463
+ else signal.addEventListener("abort", abortHandler, { once: true });
347
464
  }
348
465
  });
349
466
 
350
467
  currentResult.exitCode = exitCode;
351
- currentResult.finalOutput = getFinalOutput(currentResult.messages);
352
- if (wasAborted && !timedOut) throw new Error("Subagent was aborted");
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
+ };
353
481
  return currentResult;
354
482
  } finally {
355
483
  if (tmpPromptPath)
package/src/settings.ts CHANGED
@@ -25,6 +25,14 @@ function isPositiveNumber(value: unknown): value is number {
25
25
  return typeof value === "number" && Number.isFinite(value) && value >= 1;
26
26
  }
27
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
+
28
36
  export function normalizeAgentSettings(value: unknown): SubagentAgentConfig | undefined {
29
37
  if (!isPlainObject(value)) return undefined;
30
38
 
@@ -60,16 +68,54 @@ export function normalizeAgentSettings(value: unknown): SubagentAgentConfig | un
60
68
 
61
69
  export function normalizeSubagentSettings(value: unknown): SubagentSettings | undefined {
62
70
  if (!isPlainObject(value)) return undefined;
63
- if (!hasOwn(value, "agents")) return {};
64
- if (!isPlainObject(value.agents)) return undefined;
65
-
66
- const agents: Record<string, SubagentAgentConfig> = {};
67
- for (const [name, rawConfig] of Object.entries(value.agents)) {
68
- const config = normalizeAgentSettings(rawConfig);
69
- if (config) agents[name] = config;
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;
70
80
  }
71
-
72
- return Object.keys(agents).length > 0 ? { agents } : {};
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;
73
119
  }
74
120
 
75
121
  export function readSubagentSettings(): SubagentSettings | undefined {
@@ -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
+ }