@mystilleef/pi-subagent 0.11.0 → 0.12.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.
@@ -9,6 +9,11 @@ import readline from "node:readline";
9
9
  import type { Message } from "@earendil-works/pi-ai";
10
10
  import type { AgentConfig, ThinkingLevel } from "../agent/agents.js";
11
11
  import { getFinalOutput } from "../output/ui.js";
12
+ import {
13
+ getPiInvocation,
14
+ getSubagentDepth,
15
+ subagentDepthEnv,
16
+ } from "../shared/invocation.js";
12
17
  import { serializeSamplingParams } from "../shared/sampling.js";
13
18
  import {
14
19
  type OnUpdateCallback,
@@ -18,11 +23,9 @@ import {
18
23
  } from "../shared/types.js";
19
24
  import {
20
25
  detectMessageError,
21
- getPiInvocation,
22
- getSubagentDepth,
23
26
  getSubagentRuntimeLimits,
27
+ resolveAgentExtensionPaths,
24
28
  resolveAgentSkillArgs,
25
- subagentDepthEnv,
26
29
  } from "../shared/utils.js";
27
30
  import {
28
31
  type ChildEventParseResult,
@@ -34,15 +37,17 @@ import { getLatestOutcomeFromMessages } from "./complete-outcome.js";
34
37
  import {
35
38
  buildModelDisplay,
36
39
  type ChildModelSettings,
40
+ type ModelRegistry,
37
41
  resolveEffectiveChildModelSettings,
38
42
  resolveThinkingLevel,
39
43
  } from "./model-resolution.js";
40
44
  import {
41
45
  appendWithByteLimit,
42
46
  resolveCompleteExtensionPath,
47
+ resolvePackageExtensionPath,
43
48
  resolveSamplingExtensionPath,
44
49
  } from "./process-utils.js";
45
- import { appendSubagentResultContract } from "./prompt-contract.js";
50
+ import { SUBAGENT_RESULT_CONTRACT } from "./prompt-contract.js";
46
51
  import {
47
52
  beginPromptSetup,
48
53
  cleanupPromptSetupResult,
@@ -69,8 +74,8 @@ import {
69
74
 
70
75
  const COMPLETE_EXTENSION_PATH = resolveCompleteExtensionPath();
71
76
  const SAMPLING_EXTENSION_PATH = resolveSamplingExtensionPath();
77
+ const PACKAGE_EXTENSION_PATH = resolvePackageExtensionPath();
72
78
 
73
- export { resolveThinkingLevel } from "./model-resolution.js";
74
79
  export { makeEmitUpdate } from "./streaming-progress.js";
75
80
 
76
81
  type RuntimeLimits = ReturnType<typeof getSubagentRuntimeLimits>;
@@ -79,6 +84,7 @@ type SleepInhibitorAcquirer = (pid: number) => Promise<SleepInhibitorHandle>;
79
84
  type RunSingleAgentOptions = {
80
85
  acquireSleepInhibitor?: SleepInhibitorAcquirer;
81
86
  getOrchestratorPid?: () => unknown;
87
+ registry?: ModelRegistry | undefined;
82
88
  };
83
89
 
84
90
  export type RunSingleAgentResult =
@@ -365,15 +371,40 @@ function buildSamplingEnv(agent: AgentConfig): string | undefined {
365
371
  });
366
372
  }
367
373
 
368
- function buildPiArgs(
369
- agent: AgentConfig,
370
- task: string,
371
- effectiveModel: ChildModelSettings,
372
- thinking: ThinkingLevel,
373
- resolvedSkills: { args: string[] },
374
- tmpPrompt: { filePath: string } | null,
375
- ): string[] {
376
- const args: string[] = ["--mode", "json", "-p", "--no-session", "--approve"];
374
+ export interface BuildPiArgsConfig {
375
+ agent: AgentConfig;
376
+ task: string;
377
+ effectiveModel: ChildModelSettings;
378
+ thinking: ThinkingLevel;
379
+ resolvedSkills: { args: string[] };
380
+ tmpPrompt: { filePath: string } | null;
381
+ resolvedExtensionPaths?: string[] | undefined;
382
+ samplingEnv?: string | undefined;
383
+ }
384
+
385
+ export function buildPiArgs(config: BuildPiArgsConfig): string[] {
386
+ const {
387
+ agent,
388
+ task,
389
+ effectiveModel,
390
+ thinking,
391
+ resolvedSkills,
392
+ tmpPrompt,
393
+ resolvedExtensionPaths,
394
+ samplingEnv,
395
+ } = config;
396
+ const args: string[] = [
397
+ "--mode",
398
+ "json",
399
+ "-p",
400
+ "--no-session",
401
+ "--approve",
402
+ "--no-themes",
403
+ "--no-prompt-templates",
404
+ ];
405
+ if (agent.extensions !== undefined) {
406
+ args.push("--no-extensions");
407
+ }
377
408
  if (effectiveModel.provider && effectiveModel.id)
378
409
  args.push("--provider", effectiveModel.provider);
379
410
  if (effectiveModel.id) args.push("--model", effectiveModel.id);
@@ -383,15 +414,35 @@ function buildPiArgs(
383
414
  tools.add("complete");
384
415
  args.push("--tools", [...tools].join(","));
385
416
  }
386
- if (agent.skills) args.push("--no-skills", ...resolvedSkills.args);
417
+ if (agent.skills !== undefined)
418
+ args.push("--no-skills", ...resolvedSkills.args);
419
+ if (agent.context === false) args.push("--no-context-files");
387
420
  if (tmpPrompt) {
388
- args.push("--append-system-prompt", tmpPrompt.filePath);
421
+ if (agent.replacePrompt) {
422
+ args.push("--system-prompt", tmpPrompt.filePath);
423
+ } else {
424
+ args.push("--append-system-prompt", tmpPrompt.filePath);
425
+ }
426
+ }
427
+ if (agent.extensions !== undefined) {
428
+ args.push("--extension", PACKAGE_EXTENSION_PATH);
429
+ if (resolvedExtensionPaths && resolvedExtensionPaths.length > 0) {
430
+ for (const rp of resolvedExtensionPaths) {
431
+ if (rp !== PACKAGE_EXTENSION_PATH) {
432
+ args.push("--extension", rp);
433
+ }
434
+ }
435
+ }
389
436
  }
390
437
  args.push("--extension", COMPLETE_EXTENSION_PATH);
438
+ if (samplingEnv) {
439
+ args.push("--extension", SAMPLING_EXTENSION_PATH);
440
+ }
441
+ args.push("--append-system-prompt", SUBAGENT_RESULT_CONTRACT);
391
442
  const taskPrompt = task
392
443
  ? `Task: ${task}`
393
444
  : "Run according to your system prompt. If no explicit task was provided, use the default context described there.";
394
- args.push(appendSubagentResultContract(taskPrompt));
445
+ args.push(taskPrompt);
395
446
  return args;
396
447
  }
397
448
 
@@ -535,23 +586,38 @@ export async function runSingleAgent(
535
586
  }
536
587
  const requestedThinking = agent.thinking ?? parentThinking;
537
588
  const effectiveModel = resolveEffectiveChildModelSettings(agent, parentModel);
538
- const { level: thinking, warning: thinkingWarning } =
539
- effectiveModel.provider && effectiveModel.id
540
- ? resolveThinkingLevel(
541
- requestedThinking,
542
- effectiveModel.provider,
543
- effectiveModel.id,
544
- )
545
- : { level: requestedThinking };
589
+ const resolvedThinking = resolveThinkingLevel(
590
+ requestedThinking,
591
+ effectiveModel.provider,
592
+ effectiveModel.id,
593
+ { registry: options.registry },
594
+ );
595
+ const thinking = resolvedThinking.level;
596
+ const thinkingWarning = resolvedThinking.warning;
597
+ if (resolvedThinking.diagnostic) console.warn(resolvedThinking.diagnostic);
546
598
  const modelDisplay = buildModelDisplay(effectiveModel, thinking);
547
599
  const resolvedSkillsPromise: Promise<{ args: string[] } | { error: string }> =
548
600
  agent.skills
549
601
  ? resolveAgentSkillArgs(defaultCwd, agent.skills)
550
602
  : Promise.resolve({ args: [] });
603
+ const extensionNames =
604
+ agent.extensions && agent.extensions.length > 0
605
+ ? agent.extensions
606
+ : undefined;
607
+ const resolvedExtensionsPromise: Promise<
608
+ { resolvedPaths: string[] } | { error: string }
609
+ > = extensionNames
610
+ ? resolveAgentExtensionPaths(defaultCwd, extensionNames)
611
+ : Promise.resolve({ resolvedPaths: [] });
551
612
  const promptSetupPromise = beginPromptSetup(agent);
552
- const resolvedSkills = await resolvedSkillsPromise;
553
- if ("error" in resolvedSkills) {
554
- const promptSetup = await promptSetupPromise;
613
+ const [resolvedSkills, resolvedExtensions, promptSetup] = await Promise.all([
614
+ resolvedSkillsPromise,
615
+ resolvedExtensionsPromise,
616
+ promptSetupPromise,
617
+ ]);
618
+ const abortWithError = async (
619
+ error: string,
620
+ ): Promise<RunSingleAgentResult> => {
555
621
  await cleanupPromptSetupResult(promptSetup);
556
622
  return {
557
623
  kind: "completed",
@@ -559,13 +625,30 @@ export async function runSingleAgent(
559
625
  agentName,
560
626
  agent.source,
561
627
  task,
562
- resolvedSkills.error,
628
+ error,
629
+ modelDisplay,
630
+ ),
631
+ };
632
+ };
633
+ if ("error" in resolvedSkills) return abortWithError(resolvedSkills.error);
634
+ if ("error" in resolvedExtensions)
635
+ return abortWithError(resolvedExtensions.error);
636
+ if ("error" in promptSetup) {
637
+ return {
638
+ kind: "completed",
639
+ result: createErrorResult(
640
+ agentName,
641
+ agent.source,
642
+ task,
643
+ `Failed to write prompt: ${
644
+ promptSetup.error instanceof Error
645
+ ? promptSetup.error.message
646
+ : String(promptSetup.error)
647
+ }`,
563
648
  modelDisplay,
564
649
  ),
565
650
  };
566
651
  }
567
- const promptSetup = await promptSetupPromise;
568
- if ("error" in promptSetup) throw promptSetup.error;
569
652
  const startedAt = Date.now();
570
653
  const state: SubagentState = {
571
654
  result: initRuntimeResult(agentName, agent.source, task, modelDisplay),
@@ -576,17 +659,23 @@ export async function runSingleAgent(
576
659
  const tmpPrompt = promptSetup.tmpPrompt;
577
660
  const samplingEnv = buildSamplingEnv(agent);
578
661
  try {
579
- const args = buildPiArgs(
662
+ const args = buildPiArgs({
580
663
  agent,
581
664
  task,
582
665
  effectiveModel,
583
- thinking,
666
+ // Intentionally the raw request, not `thinking`: resolveThinkingLevel's
667
+ // clamp is only an estimate when the model isn't a confirmed live-registry
668
+ // hit, and `pi` itself makes the authoritative call. `thinking`/`thinkingWarning`
669
+ // still drive the user-facing display.
670
+ thinking: requestedThinking,
584
671
  resolvedSkills,
585
672
  tmpPrompt,
586
- );
587
- if (samplingEnv) {
588
- args.push("--extension", SAMPLING_EXTENSION_PATH);
589
- }
673
+ resolvedExtensionPaths:
674
+ agent.extensions !== undefined
675
+ ? resolvedExtensions.resolvedPaths
676
+ : undefined,
677
+ samplingEnv,
678
+ });
590
679
  const invocation = getPiInvocation(args);
591
680
  const terminateOptions = {
592
681
  tree: true,
@@ -5,16 +5,20 @@ export const SUBAGENT_RESULT_CONTRACT = `
5
5
 
6
6
  ### Directives
7
7
 
8
- - Write the complete result as a text response — mandatory, before calling \`complete\`.
9
- - Call \`complete\` as the final action; \`outcome\` carries a one-sentence progress
10
- summary only, not the result.
8
+ - Write the complete task result as assistant text — mandatory, non-empty, and trimmed,
9
+ before calling \`complete\`.
10
+ - Call \`complete\` as the final action after the result text; \`outcome\` must be a short,
11
+ one-sentence progress summary only, not the full result.
12
+ - This contract applies after any amount of tool use, reading, editing, or multi-step work.
11
13
 
12
14
  ### Constraints
13
15
 
14
- - **NEVER** omit the text response.
15
- - **NEVER** wrap the entire result in a code block.
16
+ - **NEVER** omit the result text response.
17
+ - **NEVER** emit whitespace-only, empty-string, or blank result text before \`complete\`.
18
+ - **NEVER** wrap the entire result in a code block or code fence.
19
+ - **NEVER** end the assistant response with text alone after tool calls; a terminal
20
+ \`complete\` call is required.
21
+ - **NEVER** write assistant text after \`complete\`.
22
+ - Call \`complete\` exactly once; multiple \`complete\` calls are not allowed.
23
+ - \`outcome\` must be concise and contain only a progress summary.
16
24
  `;
17
-
18
- export function appendSubagentResultContract(prompt: string): string {
19
- return `${prompt}\n\n${SUBAGENT_RESULT_CONTRACT}`;
20
- }
@@ -1,6 +1,24 @@
1
1
  import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
2
4
  import type { AgentConfig } from "../agent/agents.js";
3
- import { writePromptToTempFile } from "../shared/utils.js";
5
+
6
+ export async function writePromptToTempFile(
7
+ agentName: string,
8
+ prompt: string,
9
+ ): Promise<{ dir: string; filePath: string }> {
10
+ const tmpDir = await fs.promises.mkdtemp(
11
+ path.join(os.tmpdir(), "pi-subagent-"),
12
+ );
13
+ const safeName = agentName.replace(/[^\w.-]+/g, "_");
14
+ const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
15
+ // mkdtemp guarantees a unique directory per call; no concurrent writer can hold this path.
16
+ await fs.promises.writeFile(filePath, prompt, {
17
+ encoding: "utf-8",
18
+ mode: 0o600,
19
+ });
20
+ return { dir: tmpDir, filePath };
21
+ }
4
22
 
5
23
  export type TempPrompt = { dir: string; filePath: string };
6
24
 
@@ -11,6 +11,7 @@ import type {
11
11
  AgentScope,
12
12
  ThinkingLevel,
13
13
  } from "../agent/agents.js";
14
+ import type { ModelRegistry as ChildModelRegistry } from "../child/model-resolution.js";
14
15
  import { runSingleAgent } from "../child/process.js";
15
16
  import { deliverNotification } from "../notification/delivery.js";
16
17
  import {
@@ -39,13 +40,14 @@ import {
39
40
  sanitizeResultDetails,
40
41
  } from "../progress/result-details.js";
41
42
  import { generateSubagentInstanceName } from "../shared/instance-name.js";
43
+ import { getSubagentDepth } from "../shared/invocation.js";
42
44
  import type {
43
45
  OnUpdateCallback,
44
46
  SingleResult,
45
47
  SubagentDetails,
46
48
  SubagentToolResult,
47
49
  } from "../shared/types.js";
48
- import { getSubagentDepth, hasSubagentFailed } from "../shared/utils.js";
50
+ import { hasSubagentFailed } from "../shared/utils.js";
49
51
  import {
50
52
  listRunJobs,
51
53
  type RunJob,
@@ -102,6 +104,7 @@ interface LifecycleContext {
102
104
  task: string;
103
105
  parentModel: { provider: string; id: string } | undefined;
104
106
  parentThinking: ThinkingLevel;
107
+ registry?: ChildModelRegistry;
105
108
  hostOnUpdate?: AgentToolUpdateCallback<SubagentDetails> | undefined;
106
109
  }
107
110
 
@@ -288,6 +291,7 @@ async function runSubagentLifecycle(
288
291
  lc.parentModel,
289
292
  lc.parentThinking,
290
293
  lc.debug,
294
+ { registry: lc.registry },
291
295
  );
292
296
  if (outcome.kind === "aborted") {
293
297
  const details = lc.makeDetails([outcome.result]);
@@ -474,6 +478,7 @@ async function prepareSubagentJob(
474
478
  task,
475
479
  parentModel,
476
480
  parentThinking,
481
+ registry: ctx.modelRegistry,
477
482
  hostOnUpdate,
478
483
  },
479
484
  instanceName,
package/src/output/ui.ts CHANGED
@@ -72,6 +72,40 @@ export function formatTokens(count: number): string {
72
72
  return `${(count / 1000000).toFixed(1)}M`;
73
73
  }
74
74
 
75
+ /**
76
+ * Formats the plural-aware turn count string.
77
+ */
78
+ function formatTurns(turns: number): string {
79
+ return `${turns} turn${turns > 1 ? "s" : ""}`;
80
+ }
81
+
82
+ function formatIoTokens(input: number, output: number): string {
83
+ const tokens: string[] = [];
84
+ if (input) tokens.push(`↑${formatTokens(input)}`);
85
+ if (output) tokens.push(`↓${formatTokens(output)}`);
86
+ return tokens.join(" ");
87
+ }
88
+
89
+ function formatCacheStats(cacheRead: number, cacheWrite: number): string {
90
+ const cache: string[] = [];
91
+ if (cacheRead) cache.push(`R${formatTokens(cacheRead)}`);
92
+ if (cacheWrite) cache.push(`W${formatTokens(cacheWrite)}`);
93
+ return `cache:${cache.join("/")}`;
94
+ }
95
+
96
+ /**
97
+ * Builds common usage stat parts: turns, context tokens, and cost.
98
+ * Shared between formatUsageStats and formatResultFooter.
99
+ */
100
+ function formatUsageCore(usage: UsageStats): string[] {
101
+ const parts: string[] = [];
102
+ if (usage.turns) parts.push(formatTurns(usage.turns));
103
+ if (usage.contextTokens && usage.contextTokens > 0)
104
+ parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
105
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
106
+ return parts;
107
+ }
108
+
75
109
  /**
76
110
  * Formats cumulative usage statistics for compact UI display.
77
111
  */
@@ -81,20 +115,11 @@ export function formatUsageStats(
81
115
  compact?: boolean,
82
116
  ): string {
83
117
  const parts: string[] = [];
84
- if (usage.turns)
85
- parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
86
- if (usage.input || usage.output) {
87
- const tokens: string[] = [];
88
- if (usage.input) tokens.push(`↑${formatTokens(usage.input)}`);
89
- if (usage.output) tokens.push(`↓${formatTokens(usage.output)}`);
90
- parts.push(tokens.join(" "));
91
- }
92
- if (!compact && (usage.cacheRead || usage.cacheWrite)) {
93
- const cache: string[] = [];
94
- if (usage.cacheRead) cache.push(`R${formatTokens(usage.cacheRead)}`);
95
- if (usage.cacheWrite) cache.push(`W${formatTokens(usage.cacheWrite)}`);
96
- parts.push(`cache:${cache.join("/")}`);
97
- }
118
+ if (usage.turns) parts.push(formatTurns(usage.turns));
119
+ if (usage.input || usage.output)
120
+ parts.push(formatIoTokens(usage.input, usage.output));
121
+ if (!compact && (usage.cacheRead || usage.cacheWrite))
122
+ parts.push(formatCacheStats(usage.cacheRead, usage.cacheWrite));
98
123
  if (!compact && usage.contextTokens && usage.contextTokens > 0)
99
124
  parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
100
125
  if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
@@ -108,11 +133,7 @@ export function formatUsageStats(
108
133
  export function formatResultFooter(usage: UsageStats, model?: string): string {
109
134
  const parts: string[] = [];
110
135
  if (model) parts.push(model);
111
- if (usage.turns)
112
- parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
113
- if (usage.contextTokens && usage.contextTokens > 0)
114
- parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
115
- if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
136
+ parts.push(...formatUsageCore(usage));
116
137
  return parts.join(" · ");
117
138
  }
118
139
 
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Process invocation and subagent depth utilities.
3
+ * Handles pi CLI discovery and recursion depth tracking via environment variables.
4
+ */
5
+
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+
9
+ export function getPiInvocation(args: string[]): {
10
+ command: string;
11
+ args: string[];
12
+ } {
13
+ const currentScript = process.argv[1];
14
+ if (currentScript && fs.existsSync(currentScript)) {
15
+ return { command: process.execPath, args: [currentScript, ...args] };
16
+ }
17
+ const execName = path.basename(process.execPath).toLowerCase();
18
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
19
+ if (!isGenericRuntime) {
20
+ return { command: process.execPath, args };
21
+ }
22
+ return { command: "pi", args };
23
+ }
24
+
25
+ export function getSubagentDepth(): number {
26
+ const d = Number(process.env.PI_SUBAGENT_DEPTH ?? "0");
27
+ if (!Number.isFinite(d) || d < 0) return 0;
28
+ return Math.floor(d);
29
+ }
30
+
31
+ export function subagentDepthEnv(): Record<string, string> {
32
+ return { PI_SUBAGENT_DEPTH: String(getSubagentDepth() + 1) };
33
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Output and runtime limit configuration for subagent processes.
3
+ * Handles byte/line caps for child output and runtime safety thresholds.
4
+ */
5
+
6
+ export const DEFAULT_MAX_OUTPUT_BYTES = 50_000;
7
+ export const DEFAULT_MAX_OUTPUT_LINES = 500;
8
+ export const DEFAULT_AGENT_END_GRACE_MS = 250;
9
+ export const DEFAULT_MAX_STDERR_BYTES = 10_000;
10
+ export const DEFAULT_MAX_SUBAGENT_DEPTH = 3;
11
+ const MAX_SUBAGENT_DEPTH_CEILING = 10;
12
+
13
+ export interface SubagentOutputLimits {
14
+ maxBytes: number;
15
+ maxLines: number;
16
+ }
17
+
18
+ export interface SubagentRuntimeLimits {
19
+ agentEndGraceMs: number;
20
+ maxStderrBytes: number;
21
+ maxDepth: number;
22
+ }
23
+
24
+ type EnvLimitConfig = Partial<Record<string, string | number | undefined>>;
25
+
26
+ function parsePositiveInteger(
27
+ value: string | number | undefined,
28
+ ): number | undefined {
29
+ const parsed = typeof value === "number" ? value : Number(value);
30
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1)
31
+ return undefined;
32
+ return parsed;
33
+ }
34
+
35
+ export function getSubagentOutputLimits(
36
+ config: EnvLimitConfig = process.env,
37
+ ): SubagentOutputLimits {
38
+ return {
39
+ maxBytes:
40
+ parsePositiveInteger(config["PI_SUBAGENT_MAX_OUTPUT_BYTES"]) ??
41
+ DEFAULT_MAX_OUTPUT_BYTES,
42
+ maxLines:
43
+ parsePositiveInteger(config["PI_SUBAGENT_MAX_OUTPUT_LINES"]) ??
44
+ DEFAULT_MAX_OUTPUT_LINES,
45
+ };
46
+ }
47
+
48
+ export function getSubagentRuntimeLimits(
49
+ config: EnvLimitConfig = process.env,
50
+ ): SubagentRuntimeLimits {
51
+ const maxDepth =
52
+ parsePositiveInteger(config["PI_SUBAGENT_MAX_DEPTH"]) ??
53
+ DEFAULT_MAX_SUBAGENT_DEPTH;
54
+ return {
55
+ agentEndGraceMs:
56
+ parsePositiveInteger(config["PI_SUBAGENT_AGENT_END_GRACE_MS"]) ??
57
+ DEFAULT_AGENT_END_GRACE_MS,
58
+ maxStderrBytes:
59
+ parsePositiveInteger(config["PI_SUBAGENT_MAX_STDERR_BYTES"]) ??
60
+ DEFAULT_MAX_STDERR_BYTES,
61
+ maxDepth: Math.min(maxDepth, MAX_SUBAGENT_DEPTH_CEILING),
62
+ };
63
+ }
64
+
65
+ export function truncateOutput(
66
+ text: string,
67
+ limits: SubagentOutputLimits = getSubagentOutputLimits(),
68
+ ): string {
69
+ const lines = text.split("\n");
70
+ const maxBytes = Math.max(1, Math.floor(limits.maxBytes));
71
+ const maxLines = Math.max(1, Math.floor(limits.maxLines));
72
+ if (lines.length <= maxLines && Buffer.byteLength(text, "utf-8") <= maxBytes)
73
+ return text;
74
+ let result = lines.slice(0, maxLines).join("\n");
75
+ if (Buffer.byteLength(result, "utf-8") > maxBytes) {
76
+ const buf = Buffer.from(result).subarray(0, maxBytes);
77
+ result = buf.toString("utf-8").replace(/\uFFFD$/, "");
78
+ }
79
+ const kept = result.split("\n").length;
80
+ return `[TRUNCATED: first ${kept} of ${lines.length} lines]\n${result}`;
81
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Message processing utilities for subagent results.
3
+ * Handles assistant message extraction, error detection, and failure判定.
4
+ */
5
+
6
+ import type { Message } from "@earendil-works/pi-ai";
7
+ import type { SingleResult } from "./types.js";
8
+
9
+ export function findLastAssistantTextMessage(messages: Message[]): number {
10
+ for (let i = messages.length - 1; i >= 0; i--) {
11
+ const msg = messages[i];
12
+ if (
13
+ msg?.role === "assistant" &&
14
+ Array.isArray(msg.content) &&
15
+ msg.content.some(
16
+ (c) =>
17
+ c.type === "text" &&
18
+ typeof c.text === "string" &&
19
+ c.text.trim().length > 0,
20
+ )
21
+ ) {
22
+ return i;
23
+ }
24
+ }
25
+ return -1;
26
+ }
27
+
28
+ export function extractFinalOutputFromMessages(messages: Message[]): string {
29
+ const lastAsstIdx = findLastAssistantTextMessage(messages);
30
+ if (lastAsstIdx < 0) return "";
31
+ const content = messages[lastAsstIdx]?.content;
32
+ if (!Array.isArray(content)) return "";
33
+ const lastText = content.findLast((p) => p.type === "text");
34
+ return lastText?.type === "text" ? (lastText.text ?? "") : "";
35
+ }
36
+
37
+ export function detectMessageError(messages: Message[]): boolean {
38
+ const lastAssistantIdx = findLastAssistantTextMessage(messages);
39
+ const from = lastAssistantIdx >= 0 ? lastAssistantIdx + 1 : 0;
40
+ for (let i = messages.length - 1; i >= from; i--) {
41
+ const msg = messages[i];
42
+ if (msg?.role === "toolResult" && msg.isError) return true;
43
+ }
44
+ return false;
45
+ }
46
+
47
+ export function hasSubagentFailed(result: SingleResult): boolean {
48
+ if (result.outcome?.trim()) return false;
49
+ return (
50
+ result.exitCode !== 0 ||
51
+ result.stopReason === "error" ||
52
+ result.stopReason === "aborted" ||
53
+ Boolean(result.errorMessage?.trim()) ||
54
+ detectMessageError(result.messages ?? [])
55
+ );
56
+ }