@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 +31 -2
- package/lib/agent-graph.ts +115 -0
- package/lib/cycle-signature.ts +153 -0
- package/lib/depth-limit.ts +35 -0
- package/package.json +1 -1
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 =
|
|
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
|
|
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