@rohaquinlop/pi-subagents 0.5.0 → 0.5.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/index.ts CHANGED
@@ -19,6 +19,9 @@ import { discoverAgents, mergeAgents, substitutePlaceholders, formatConnectorCon
19
19
  import { zeroUsage, accumulateUsage, validateAgents, MAX_LOOP_CONTEXT, parseJudgeVerdict } from "./lib/pipeline-helpers";
20
20
  import { buildSubagentErrorContent, buildPipelineErrorContent, buildLoopErrorContent } from "./lib/error-helpers";
21
21
  import { detectCycle } from "./lib/loop-detector";
22
+ import { extractCycleSignature, LOOP_PRIOR_ITERATIONS_HEADER } from "./lib/cycle-signature";
23
+ import { validateAgentGraphAcyclicity } from "./lib/agent-graph";
24
+ import { checkDepth } from "./lib/depth-limit";
22
25
 
23
26
  interface ToolEvent {
24
27
  tool: string;
@@ -83,6 +86,7 @@ interface ExtensionConfig {
83
86
  maxConcurrency?: number;
84
87
  subagentTimeoutMs?: number; // wall-clock, default 600000 (10 min). 0 = disabled.
85
88
  subagentIdleTimeoutMs?: number; // no-stdout watchdog, default 300000 (5 min). 0 = disabled.
89
+ maxSubagentDepth?: number; // max nesting depth, default 8. Hard backstop against recursion loops.
86
90
  }
87
91
 
88
92
  const EXT_DIR = path.dirname(new URL(import.meta.url).pathname);
@@ -379,6 +383,15 @@ async function buildPiArgs(
379
383
  cwd: string,
380
384
  ): Promise<{ args: string[]; tempDir: string; childEnv: NodeJS.ProcessEnv }> {
381
385
  const piBin = resolvePiBinary();
386
+
387
+ // Depth check (before resource allocation to avoid temp-dir leaks on throw)
388
+ const currentDepth = parseInt(process.env.PI_SUBAGENT_DEPTH || "0", 10);
389
+ const maxDepth = extensionConfig.maxSubagentDepth ?? 8;
390
+ const depthResult = checkDepth(currentDepth, maxDepth);
391
+ if (!depthResult.allowed) {
392
+ throw new Error(depthResult.error!);
393
+ }
394
+
382
395
  const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-sub-"));
383
396
 
384
397
  // Write system prompt to temp file
@@ -460,6 +473,8 @@ async function buildPiArgs(
460
473
  childEnv.PI_SUBAGENT_ALLOWED = agent.subagentAgents.join(",");
461
474
  }
462
475
 
476
+ childEnv.PI_SUBAGENT_DEPTH = String(depthResult.newDepth);
477
+
463
478
  return { args: [piBin.command, ...args], tempDir, childEnv };
464
479
  }
465
480
 
@@ -565,6 +580,13 @@ async function runSubagent(
565
580
  let childClosed = false;
566
581
  let sigkillTimer: ReturnType<typeof setTimeout> | undefined;
567
582
  let callHistory: string[] = []; // sliding window of tool-call signatures for cycle detection
583
+ // NOTE: Cycle detection is PER-CHILD — each runSubagent() call has its own
584
+ // fresh callHistory. Cross-step loops in pipeline (A→B→A→B) and cross-iteration
585
+ // re-delegation in loop are NOT caught by the tool-call detector. They are
586
+ // bounded instead by: pipeline's 2-5 step cap, loop's 2-5 iteration cap, the
587
+ // depth cap (default 8), and wall-clock (10 min) + idle (5 min) timeouts.
588
+ // This is a deliberate tradeoff — per-child detection avoids false positives
589
+ // from parallel siblings sharing history.
568
590
 
569
591
  const safeResolve = (code: number) => {
570
592
  if (resolved) return;
@@ -643,7 +665,7 @@ async function runSubagent(
643
665
  // ── Cycle detection (parent-side, context-free) ──
644
666
  // Signature = toolName + args preview. Two calls with different args
645
667
  // (different file, or same file different content) → different sig.
646
- const sig = `${evt.toolName}:${extractToolArgsPreview((evt.args || {}) as Record<string, unknown>)}`;
668
+ const sig = extractCycleSignature(evt.toolName, (evt.args || {}) as Record<string, unknown>);
647
669
  const cycleResult = detectCycle(callHistory, sig);
648
670
  callHistory.push(sig);
649
671
  if (callHistory.length > 24) callHistory = callHistory.slice(-24);
@@ -1145,7 +1167,7 @@ async function runLoop(
1145
1167
  }
1146
1168
  }
1147
1169
  const contextBlock = keptOutputs.join("\n\n");
1148
- fullTask = `${task}\n\n## Prior iterations:\n${contextBlock}`;
1170
+ fullTask = `${task}\n\n${LOOP_PRIOR_ITERATIONS_HEADER}\n${contextBlock}`;
1149
1171
  }
1150
1172
 
1151
1173
  const iterStart = Date.now();
@@ -1375,6 +1397,13 @@ export default function (pi: ExtensionAPI) {
1375
1397
  semaphore = new Semaphore(extensionConfig.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY);
1376
1398
  agents = loadAgents();
1377
1399
 
1400
+ // Validate agent graph acyclicity (warning only — depth cap backstops recursion)
1401
+ const cycleError = validateAgentGraphAcyclicity(agents);
1402
+ if (cycleError) {
1403
+ console.error(`[pi-subagents] WARNING: ${cycleError}`);
1404
+ console.error(`[pi-subagents] The depth cap (maxSubagentDepth=${extensionConfig.maxSubagentDepth ?? 8}) prevents infinite recursion, but this agent configuration should be fixed.`);
1405
+ }
1406
+
1378
1407
  // If spawned as a child by a parent subagent process, PI_SUBAGENT_ALLOWED
1379
1408
  // pins which agents we're allowed to expose. Filter the registry now, before
1380
1409
  // any tool description sees the agent list — the child LLM should not even
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Agent graph validation — detects cycles in agent delegation graphs.
3
+ *
4
+ * Uses WHITE/GRAY/BLACK DFS coloring to detect back edges (GRAY neighbor = cycle).
5
+ * Returns null if acyclic, or a human-readable cycle string if cyclic.
6
+ */
7
+ import type { AgentConfig } from "./types";
8
+
9
+ type Color = "white" | "gray" | "black";
10
+
11
+ /**
12
+ * Validate that the agent delegation graph is acyclic.
13
+ *
14
+ * Builds a directed graph from agent.subagentAgents references and runs
15
+ * DFS cycle detection. Handles: self-loops, 2-node cycles, N-node cycles,
16
+ * disconnected components, and references to agents not in the graph
17
+ * (skipped — they're external/leaf nodes).
18
+ *
19
+ * @param agents Array of agent configurations
20
+ * @returns null if acyclic, or a human-readable cycle string like
21
+ * "Cycle detected in agent delegation graph: A → B → A"
22
+ */
23
+ export function validateAgentGraphAcyclicity(agents: AgentConfig[]): string | null {
24
+ // Build adjacency list from agent configs
25
+ const agentNames = new Set(agents.map((a) => a.name));
26
+ const graph = new Map<string, string[]>();
27
+
28
+ for (const agent of agents) {
29
+ const targets = agent.subagentAgents ?? [];
30
+ // Filter out references to non-existent agents (external/leaf nodes)
31
+ graph.set(agent.name, targets.filter((t) => agentNames.has(t)));
32
+ }
33
+
34
+ // WHITE/GRAY/BLACK DFS coloring
35
+ const color = new Map<string, Color>();
36
+ const parent = new Map<string, string | null>();
37
+
38
+ for (const name of agentNames) {
39
+ color.set(name, "white");
40
+ }
41
+
42
+ // DFS from each unvisited node (handles disconnected components)
43
+ for (const start of agentNames) {
44
+ if (color.get(start) !== "white") continue;
45
+
46
+ // Iterative DFS using explicit stack
47
+ // Stack entries: [node, iterator index into neighbors]
48
+ const stack: Array<{ node: string; neighborIdx: number }> = [];
49
+ stack.push({ node: start, neighborIdx: 0 });
50
+ color.set(start, "gray");
51
+ parent.set(start, null);
52
+
53
+ while (stack.length > 0) {
54
+ const frame = stack[stack.length - 1];
55
+ const neighbors = graph.get(frame.node) ?? [];
56
+
57
+ if (frame.neighborIdx < neighbors.length) {
58
+ const neighbor = neighbors[frame.neighborIdx];
59
+ frame.neighborIdx++;
60
+
61
+ if (color.get(neighbor) === "gray") {
62
+ // Back edge found — reconstruct cycle path
63
+ const cyclePath = reconstructCyclePath(stack, neighbor);
64
+ return `Cycle detected in agent delegation graph: ${cyclePath}`;
65
+ }
66
+
67
+ if (color.get(neighbor) === "white") {
68
+ color.set(neighbor, "gray");
69
+ parent.set(neighbor, frame.node);
70
+ stack.push({ node: neighbor, neighborIdx: 0 });
71
+ }
72
+ // If black, it's a cross/forward edge — skip
73
+ } else {
74
+ // All neighbors explored — backtrack
75
+ color.set(frame.node, "black");
76
+ stack.pop();
77
+ }
78
+ }
79
+ }
80
+
81
+ return null;
82
+ }
83
+
84
+ /**
85
+ * Reconstruct the cycle path from the DFS stack when a back edge is found.
86
+ * The back edge goes from the current top of stack to `target`.
87
+ * The cycle is: target → ... → top → target
88
+ */
89
+ function reconstructCyclePath(
90
+ stack: Array<{ node: string; neighborIdx: number }>,
91
+ target: string,
92
+ ): string {
93
+ // Find target in the stack
94
+ let targetIdx = -1;
95
+ for (let i = 0; i < stack.length; i++) {
96
+ if (stack[i].node === target) {
97
+ targetIdx = i;
98
+ break;
99
+ }
100
+ }
101
+
102
+ if (targetIdx === -1) {
103
+ // Self-loop case: target is the current node
104
+ return `${target} → ${target}`;
105
+ }
106
+
107
+ // Build path from target to current top, then back to target
108
+ const path: string[] = [];
109
+ for (let i = targetIdx; i < stack.length; i++) {
110
+ path.push(stack[i].node);
111
+ }
112
+ path.push(target);
113
+
114
+ return path.join(" → ");
115
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Cycle-detection signature builder for tool calls.
3
+ *
4
+ * Unlike `extractToolArgsPreview` (optimized for TUI display), this module
5
+ * retains distinguishing arguments so that different calls doing different
6
+ * work produce different signatures (no false positives) while identical
7
+ * re-invocations collapse (true positives preserved).
8
+ */
9
+ import * as crypto from "node:crypto";
10
+
11
+ /**
12
+ * Header injected by the loop tool when accumulating prior iteration context
13
+ * into a subagent task. Shared here so normalizeTaskForSignature stays in sync
14
+ * with the injection site in index.ts — changing one without the other would
15
+ * silently break loop-context stripping (false-negative loop detection).
16
+ */
17
+ export const LOOP_PRIOR_ITERATIONS_HEADER = "## Prior iterations:";
18
+
19
+ /**
20
+ * Deterministic 48-bit fingerprint (12 hex chars) via SHA-256.
21
+ * Used for cycle-detection signatures: same input → same fingerprint,
22
+ * different inputs → different fingerprints (collision-resistant enough
23
+ * for a 24-entry sliding window).
24
+ */
25
+ export function taskFingerprint(str: string): string {
26
+ return crypto.createHash("sha256").update(str, "utf8").digest("hex").slice(0, 12);
27
+ }
28
+
29
+ /**
30
+ * Conservatively normalize a subagent task string for cycle-signature hashing.
31
+ * Strips injected connector context (## Prior iterations:, ## Key findings,
32
+ * ## Implementation results, ## Research findings) which varies per invocation
33
+ * but is not the "real" task, then collapses whitespace and lowercases.
34
+ * Preserves the core task instruction.
35
+ *
36
+ * Limitation: only strips the 4 known headers. A user-defined agent with a
37
+ * connector header NOT in this set would leave its {previous} content in the
38
+ * task hash, causing two identical logical tasks with varying prior context to
39
+ * produce different hashes — the detector could miss a loop (false negative).
40
+ * This is a deliberate tradeoff (conservative normalization preserves real task
41
+ * instructions; aggressive stripping risks over-collapsing different tasks).
42
+ * Residual risk is bounded by loop/pipeline/depth/timeout caps.
43
+ */
44
+ export function normalizeTaskForSignature(task: string): string {
45
+ const stripped = task
46
+ .replace(new RegExp(LOOP_PRIOR_ITERATIONS_HEADER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "[\\s\\S]*$", "m"), "")
47
+ .replace(/## Key findings[\s\S]*$/m, "")
48
+ .replace(/## Implementation results[\s\S]*$/m, "")
49
+ .replace(/## Research findings[\s\S]*$/m, "");
50
+ return stripped.replace(/\s+/g, " ").trim().toLowerCase();
51
+ }
52
+
53
+ /** Collapse any whitespace run (incl. newlines) into a single space. */
54
+ function flatten(s: string): string {
55
+ return s.replace(/\s+/g, " ").trim();
56
+ }
57
+
58
+ const MAX_CYCLE_SIG_COMPONENT = 120;
59
+
60
+ /**
61
+ * Build a cycle-detection signature for a tool call. Unlike extractToolArgsPreview
62
+ * (optimized for TUI display), this retains distinguishing arguments so that
63
+ * different calls doing different work produce different signatures (no false
64
+ * positives) while identical re-invocations collapse (true positives preserved).
65
+ * Components are capped to bound memory in the 24-entry sliding window.
66
+ */
67
+ export function extractCycleSignature(toolName: string, args: Record<string, unknown>): string {
68
+ const cap = (s: string, max: number = MAX_CYCLE_SIG_COMPONENT) =>
69
+ s.length > max ? s.slice(0, max) : s;
70
+
71
+ switch (toolName) {
72
+ case "bash":
73
+ case "safe_bash": {
74
+ const cmd = cap(flatten(String(args.command || "")));
75
+ const cwd = args.cwd ? `:${cap(flatten(String(args.cwd)), 60)}` : "";
76
+ return `${toolName}:${cmd}${cwd}`;
77
+ }
78
+ case "read": {
79
+ // Include offset (and limit) so pagination of the same file is progress, not a loop.
80
+ const p = cap(flatten(String(args.path || "")));
81
+ const offset = args.offset ?? 0;
82
+ const limit = args.limit ?? "";
83
+ return `read:${p}@${offset}${limit !== "" ? "/" + limit : ""}`;
84
+ }
85
+ case "write": {
86
+ const p = cap(flatten(String(args.path || "")));
87
+ const contentHash = args.content
88
+ ? taskFingerprint(String(args.content).slice(0, 1000))
89
+ : "nocontent";
90
+ return `write:${p}:${contentHash}`;
91
+ }
92
+ case "edit": {
93
+ const p = cap(flatten(String(args.path || "")));
94
+ let body = "";
95
+ if (Array.isArray(args.edits) && args.edits.length > 0) {
96
+ body = (args.edits as Array<{ newText?: string; new_string?: string }>)
97
+ .map((e) => String(e?.newText || e?.new_string || ""))
98
+ .join("\n");
99
+ } else {
100
+ body = String(args.newText || args.new_string || args.content || "");
101
+ }
102
+ const contentHash = body ? taskFingerprint(body.slice(0, 500)) : "noedit";
103
+ return `edit:${p}:${contentHash}`;
104
+ }
105
+ case "grep": {
106
+ const pat = cap(flatten(String(args.pattern || "")));
107
+ const p = args.path ? `:${cap(flatten(String(args.path)), 60)}` : "";
108
+ const ex = args.exclude ? `!${cap(flatten(String(args.exclude)), 40)}` : "";
109
+ return `grep:${pat}${p}${ex}`;
110
+ }
111
+ case "find":
112
+ case "fffind": {
113
+ const pat = cap(flatten(String(args.pattern || "")));
114
+ const p = args.path ? `:${cap(flatten(String(args.path)), 60)}` : "";
115
+ return `${toolName}:${pat}${p}`;
116
+ }
117
+ case "ffgrep": {
118
+ const pat = cap(flatten(String(args.pattern || "")));
119
+ const p = args.path ? `:${cap(flatten(String(args.path)), 60)}` : "";
120
+ return `ffgrep:${pat}${p}`;
121
+ }
122
+ case "ls": {
123
+ return `ls:${cap(flatten(String(args.path || ".")))}`;
124
+ }
125
+ case "web_search": {
126
+ return `web_search:${cap(flatten(String(args.query || "")))}`;
127
+ }
128
+ case "web_fetch": {
129
+ return `web_fetch:${cap(flatten(String(args.url || "")))}`;
130
+ }
131
+ case "subagent": {
132
+ // CRITICAL: include task fingerprint so different delegations are distinguishable.
133
+ const agent = flatten(String(args.agent || ""));
134
+ const taskHash = args.task
135
+ ? taskFingerprint(normalizeTaskForSignature(String(args.task)))
136
+ : "notask";
137
+ return `subagent:${agent}:${taskHash}`;
138
+ }
139
+ case "gh_cli": {
140
+ return `gh_cli:${cap(flatten(String(args.command || "")))}`;
141
+ }
142
+ case "read_pdf": {
143
+ return `read_pdf:${cap(flatten(String(args.path || "")))}`;
144
+ }
145
+ case "clarification_ui": {
146
+ return `clarification_ui:${cap(flatten(JSON.stringify(args)))}`;
147
+ }
148
+ default: {
149
+ const s = JSON.stringify(args);
150
+ return `${toolName}:${cap(flatten(s))}`;
151
+ }
152
+ }
153
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Depth-limiting logic for subagent nesting.
3
+ *
4
+ * Pure helper functions for checking and propagating subagent depth.
5
+ * Extracted for testability — the actual depth enforcement happens in
6
+ * buildPiArgs() in index.ts.
7
+ */
8
+
9
+ export interface DepthCheckResult {
10
+ allowed: boolean;
11
+ newDepth: number;
12
+ error?: string;
13
+ }
14
+
15
+ /**
16
+ * Check whether a new subagent spawn is allowed given the current depth and max.
17
+ *
18
+ * @param currentDepth The current nesting depth (from PI_SUBAGENT_DEPTH env var)
19
+ * @param maxDepth The maximum allowed depth (from config or default of 8)
20
+ * @returns DepthCheckResult with allowed flag, newDepth, and optional error message
21
+ */
22
+ export function checkDepth(currentDepth: number, maxDepth: number): DepthCheckResult {
23
+ const newDepth = currentDepth + 1;
24
+ if (newDepth > maxDepth) {
25
+ return {
26
+ allowed: false,
27
+ newDepth,
28
+ error:
29
+ `Subagent depth limit exceeded (${newDepth} > ${maxDepth}). ` +
30
+ `This likely indicates a recursion loop in agent delegation. ` +
31
+ `Set maxSubagentDepth in config.json to increase the limit.`,
32
+ };
33
+ }
34
+ return { allowed: true, newDepth };
35
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rohaquinlop/pi-subagents",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Pi extension for delegating tasks to subagents — parallel execution, agent discovery, and TUI rendering",
5
5
  "keywords": [
6
6
  "pi-package",