@rohaquinlop/pi-subagents 0.4.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/agents/worker.md +1 -0
- package/index.ts +186 -18
- package/lib/agent-graph.ts +115 -0
- package/lib/cycle-signature.ts +153 -0
- package/lib/depth-limit.ts +35 -0
- package/lib/error-helpers.ts +66 -0
- package/lib/loop-detector.ts +40 -0
- package/package.json +40 -40
- package/tools/safe-bash.ts +5 -2
package/agents/worker.md
CHANGED
|
@@ -16,6 +16,7 @@ Guidelines:
|
|
|
16
16
|
- Read files before editing to understand existing code
|
|
17
17
|
- Make targeted edits, not wholesale rewrites
|
|
18
18
|
- Use safe_bash for running commands (tests, builds, installs, etc.)
|
|
19
|
+
- `safe_bash` has a 5-minute default timeout (300s). For long-running commands (builds, tests, installs), pass a larger `timeout`, e.g. `timeout: 600` for 10 minutes.
|
|
19
20
|
- If something fails, diagnose and fix it
|
|
20
21
|
- Report what you did and what changed when done
|
|
21
22
|
|
package/index.ts
CHANGED
|
@@ -17,6 +17,11 @@ import "./tools/safe-bash";
|
|
|
17
17
|
import type { AgentConfig, AgentUsage, PipelineStepResult, PipelineResult, LoopIterationResult, LoopResult } from "./lib/types";
|
|
18
18
|
import { discoverAgents, mergeAgents, substitutePlaceholders, formatConnectorContext } from "./lib/helpers";
|
|
19
19
|
import { zeroUsage, accumulateUsage, validateAgents, MAX_LOOP_CONTEXT, parseJudgeVerdict } from "./lib/pipeline-helpers";
|
|
20
|
+
import { buildSubagentErrorContent, buildPipelineErrorContent, buildLoopErrorContent } from "./lib/error-helpers";
|
|
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";
|
|
20
25
|
|
|
21
26
|
interface ToolEvent {
|
|
22
27
|
tool: string;
|
|
@@ -55,6 +60,7 @@ interface AgentProgress {
|
|
|
55
60
|
durationMs: number;
|
|
56
61
|
lastMessage: string;
|
|
57
62
|
error?: string;
|
|
63
|
+
warning?: string;
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
interface AgentResult {
|
|
@@ -78,6 +84,9 @@ interface Details {
|
|
|
78
84
|
|
|
79
85
|
interface ExtensionConfig {
|
|
80
86
|
maxConcurrency?: number;
|
|
87
|
+
subagentTimeoutMs?: number; // wall-clock, default 600000 (10 min). 0 = disabled.
|
|
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.
|
|
81
90
|
}
|
|
82
91
|
|
|
83
92
|
const EXT_DIR = path.dirname(new URL(import.meta.url).pathname);
|
|
@@ -85,6 +94,10 @@ const AGENTS_DIR = path.join(EXT_DIR, "agents");
|
|
|
85
94
|
const TOOLS_DIR = path.join(EXT_DIR, "tools");
|
|
86
95
|
const CONFIG_PATH = path.join(EXT_DIR, "config.json");
|
|
87
96
|
const DEFAULT_MAX_CONCURRENCY = 4;
|
|
97
|
+
const DEFAULT_SUBAGENT_TIMEOUT_MS = 600_000; // 10 minutes
|
|
98
|
+
const DEFAULT_SUBAGENT_IDLE_TIMEOUT_MS = 300_000; // 5 minutes
|
|
99
|
+
|
|
100
|
+
let extensionConfig: ExtensionConfig = {};
|
|
88
101
|
|
|
89
102
|
function loadConfig(): ExtensionConfig {
|
|
90
103
|
try {
|
|
@@ -370,6 +383,15 @@ async function buildPiArgs(
|
|
|
370
383
|
cwd: string,
|
|
371
384
|
): Promise<{ args: string[]; tempDir: string; childEnv: NodeJS.ProcessEnv }> {
|
|
372
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
|
+
|
|
373
395
|
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-sub-"));
|
|
374
396
|
|
|
375
397
|
// Write system prompt to temp file
|
|
@@ -451,6 +473,8 @@ async function buildPiArgs(
|
|
|
451
473
|
childEnv.PI_SUBAGENT_ALLOWED = agent.subagentAgents.join(",");
|
|
452
474
|
}
|
|
453
475
|
|
|
476
|
+
childEnv.PI_SUBAGENT_DEPTH = String(depthResult.newDepth);
|
|
477
|
+
|
|
454
478
|
return { args: [piBin.command, ...args], tempDir, childEnv };
|
|
455
479
|
}
|
|
456
480
|
|
|
@@ -479,6 +503,11 @@ function flatten(s: string): string {
|
|
|
479
503
|
// doesn't need to read inline anyway.
|
|
480
504
|
const MAX_ARG_PREVIEW = 4000;
|
|
481
505
|
|
|
506
|
+
// Hard cap on recentTools entries to prevent unbounded memory growth in
|
|
507
|
+
// long-running subagents. Generous for expanded-view history; matches the
|
|
508
|
+
// callHistory trim pattern.
|
|
509
|
+
const MAX_RECENT_TOOLS = 50;
|
|
510
|
+
|
|
482
511
|
function extractToolArgsPreview(args: Record<string, unknown>): string {
|
|
483
512
|
const cap = (s: string) => (s.length > MAX_ARG_PREVIEW ? s.slice(0, MAX_ARG_PREVIEW) + "…" : s);
|
|
484
513
|
if (args.command) return cap(flatten(String(args.command)));
|
|
@@ -502,7 +531,7 @@ async function runSubagent(
|
|
|
502
531
|
task: string,
|
|
503
532
|
cwd: string,
|
|
504
533
|
signal: AbortSignal | undefined,
|
|
505
|
-
onUpdate?: (progress: AgentProgress, usage: AgentResult["usage"]) => void,
|
|
534
|
+
onUpdate?: (progress: AgentProgress, usage: AgentResult["usage"], finalExitCode?: number) => void,
|
|
506
535
|
): Promise<AgentResult> {
|
|
507
536
|
const { args, tempDir, childEnv } = await buildPiArgs(agent, task, cwd);
|
|
508
537
|
const command = args[0];
|
|
@@ -544,14 +573,80 @@ async function runSubagent(
|
|
|
544
573
|
|
|
545
574
|
let buf = "";
|
|
546
575
|
let stderrBuf = "";
|
|
576
|
+
let resolved = false;
|
|
577
|
+
let wallTimer: ReturnType<typeof setTimeout> | undefined;
|
|
578
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
579
|
+
let inFlightToolCount = 0;
|
|
580
|
+
let childClosed = false;
|
|
581
|
+
let sigkillTimer: ReturnType<typeof setTimeout> | undefined;
|
|
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.
|
|
590
|
+
|
|
591
|
+
const safeResolve = (code: number) => {
|
|
592
|
+
if (resolved) return;
|
|
593
|
+
resolved = true;
|
|
594
|
+
clearTimeout(wallTimer);
|
|
595
|
+
clearTimeout(idleTimer);
|
|
596
|
+
clearTimeout(sigkillTimer);
|
|
597
|
+
if (signal) signal.removeEventListener("abort", abortKill);
|
|
598
|
+
resolve(code);
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
// Wall-clock timeout
|
|
602
|
+
const timeoutMs = extensionConfig.subagentTimeoutMs ?? DEFAULT_SUBAGENT_TIMEOUT_MS;
|
|
603
|
+
if (timeoutMs > 0) {
|
|
604
|
+
wallTimer = setTimeout(() => {
|
|
605
|
+
if (!progress.error) progress.error = `Subagent timed out after ${Math.round(timeoutMs / 1000)}s`;
|
|
606
|
+
proc.kill("SIGTERM");
|
|
607
|
+
clearTimeout(sigkillTimer);
|
|
608
|
+
sigkillTimer = setTimeout(() => {
|
|
609
|
+
if (!childClosed) proc.kill("SIGKILL");
|
|
610
|
+
}, 5000);
|
|
611
|
+
}, timeoutMs);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Idle (no-stdout) watchdog
|
|
615
|
+
const idleMs = extensionConfig.subagentIdleTimeoutMs ?? DEFAULT_SUBAGENT_IDLE_TIMEOUT_MS;
|
|
616
|
+
const resetIdle = () => {
|
|
617
|
+
if (idleMs <= 0) return;
|
|
618
|
+
clearTimeout(idleTimer);
|
|
619
|
+
idleTimer = setTimeout(() => {
|
|
620
|
+
if (!progress.error) progress.error = `Subagent idle for ${Math.round(idleMs / 1000)}s — likely stuck`;
|
|
621
|
+
proc.kill("SIGTERM");
|
|
622
|
+
clearTimeout(sigkillTimer);
|
|
623
|
+
sigkillTimer = setTimeout(() => {
|
|
624
|
+
if (!childClosed) proc.kill("SIGKILL");
|
|
625
|
+
}, 5000);
|
|
626
|
+
}, idleMs);
|
|
627
|
+
};
|
|
628
|
+
resetIdle();
|
|
629
|
+
|
|
630
|
+
const pauseIdle = () => {
|
|
631
|
+
clearTimeout(idleTimer);
|
|
632
|
+
idleTimer = undefined;
|
|
633
|
+
};
|
|
634
|
+
const resumeIdle = () => {
|
|
635
|
+
if (inFlightToolCount === 0) resetIdle();
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
const MAX_STDERR_BYTES = 100_000;
|
|
547
639
|
|
|
548
640
|
const processLine = (line: string) => {
|
|
641
|
+
if (inFlightToolCount === 0) resetIdle();
|
|
549
642
|
if (!line.trim()) return;
|
|
550
643
|
try {
|
|
551
644
|
const evt = JSON.parse(line) as any;
|
|
552
645
|
progress.durationMs = Date.now() - startTime;
|
|
553
646
|
|
|
554
647
|
if (evt.type === "tool_execution_start") {
|
|
648
|
+
inFlightToolCount++;
|
|
649
|
+
pauseIdle();
|
|
555
650
|
progress.toolCount++;
|
|
556
651
|
progress.recentTools.push({
|
|
557
652
|
tool: evt.toolName,
|
|
@@ -559,6 +654,37 @@ async function runSubagent(
|
|
|
559
654
|
toolCallId: evt.toolCallId,
|
|
560
655
|
status: "running",
|
|
561
656
|
});
|
|
657
|
+
// Trim oldest completed entries, but never evict an in-flight tool —
|
|
658
|
+
// otherwise tool_execution_end's .find(toolCallId) would no-op and leave a
|
|
659
|
+
// permanently-"running" ghost.
|
|
660
|
+
while (progress.recentTools.length > MAX_RECENT_TOOLS) {
|
|
661
|
+
const idx = progress.recentTools.findIndex((t) => t.status !== "running");
|
|
662
|
+
if (idx === -1) break; // only running entries left — don't evict in-flight
|
|
663
|
+
progress.recentTools.splice(idx, 1);
|
|
664
|
+
}
|
|
665
|
+
// ── Cycle detection (parent-side, context-free) ──
|
|
666
|
+
// Signature = toolName + args preview. Two calls with different args
|
|
667
|
+
// (different file, or same file different content) → different sig.
|
|
668
|
+
const sig = extractCycleSignature(evt.toolName, (evt.args || {}) as Record<string, unknown>);
|
|
669
|
+
const cycleResult = detectCycle(callHistory, sig);
|
|
670
|
+
callHistory.push(sig);
|
|
671
|
+
if (callHistory.length > 24) callHistory = callHistory.slice(-24);
|
|
672
|
+
|
|
673
|
+
if (cycleResult.cycle) {
|
|
674
|
+
const toolNames = (cycleResult.pattern || []).map((s) => {
|
|
675
|
+
const colonIdx = s.indexOf(":");
|
|
676
|
+
return colonIdx >= 0 ? s.slice(0, colonIdx) : s;
|
|
677
|
+
});
|
|
678
|
+
const patternStr = toolNames.join("→");
|
|
679
|
+
if (!progress.error) {
|
|
680
|
+
progress.error = `Subagent stuck in a tool-call loop: repeating ${patternStr}`;
|
|
681
|
+
}
|
|
682
|
+
proc.kill("SIGTERM");
|
|
683
|
+
clearTimeout(sigkillTimer);
|
|
684
|
+
sigkillTimer = setTimeout(() => {
|
|
685
|
+
if (!childClosed) proc.kill("SIGKILL");
|
|
686
|
+
}, 5000);
|
|
687
|
+
}
|
|
562
688
|
fireUpdate();
|
|
563
689
|
}
|
|
564
690
|
|
|
@@ -593,6 +719,8 @@ async function runSubagent(
|
|
|
593
719
|
hit.children = finalChildren as AgentResult[];
|
|
594
720
|
}
|
|
595
721
|
}
|
|
722
|
+
inFlightToolCount = Math.max(0, inFlightToolCount - 1);
|
|
723
|
+
resumeIdle();
|
|
596
724
|
fireUpdate();
|
|
597
725
|
}
|
|
598
726
|
|
|
@@ -659,26 +787,37 @@ async function runSubagent(
|
|
|
659
787
|
});
|
|
660
788
|
|
|
661
789
|
proc.stderr.on("data", (d: Buffer) => {
|
|
790
|
+
if (stderrBuf.length >= MAX_STDERR_BYTES) return;
|
|
662
791
|
stderrBuf += d.toString();
|
|
792
|
+
if (stderrBuf.length >= MAX_STDERR_BYTES) {
|
|
793
|
+
stderrBuf = stderrBuf.slice(0, MAX_STDERR_BYTES) + "\n[stderr truncated]";
|
|
794
|
+
}
|
|
663
795
|
});
|
|
664
796
|
|
|
665
797
|
proc.on("close", (code) => {
|
|
798
|
+
childClosed = true;
|
|
666
799
|
if (buf.trim()) processLine(buf);
|
|
667
800
|
if (code !== 0 && stderrBuf.trim() && !progress.error) {
|
|
668
801
|
progress.error = stderrBuf.trim();
|
|
802
|
+
} else if (code === 0 && stderrBuf.trim()) {
|
|
803
|
+
// Non-fatal: surface stderr (e.g. deprecation warnings) on a successful exit.
|
|
804
|
+
progress.warning = stderrBuf.trim().slice(0, 2000);
|
|
669
805
|
}
|
|
670
|
-
|
|
806
|
+
safeResolve(code ?? 1);
|
|
671
807
|
});
|
|
672
808
|
|
|
673
|
-
proc.on("error", () =>
|
|
809
|
+
proc.on("error", () => safeResolve(1));
|
|
674
810
|
|
|
811
|
+
const abortKill = () => {
|
|
812
|
+
proc.kill("SIGTERM");
|
|
813
|
+
clearTimeout(sigkillTimer);
|
|
814
|
+
sigkillTimer = setTimeout(() => {
|
|
815
|
+
if (!childClosed) proc.kill("SIGKILL");
|
|
816
|
+
}, 3000);
|
|
817
|
+
};
|
|
675
818
|
if (signal) {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
setTimeout(() => !proc.killed && proc.kill("SIGKILL"), 3000);
|
|
679
|
-
};
|
|
680
|
-
if (signal.aborted) kill();
|
|
681
|
-
else signal.addEventListener("abort", kill, { once: true });
|
|
819
|
+
if (signal.aborted) abortKill();
|
|
820
|
+
else signal.addEventListener("abort", abortKill, { once: true });
|
|
682
821
|
}
|
|
683
822
|
});
|
|
684
823
|
|
|
@@ -690,6 +829,13 @@ async function runSubagent(
|
|
|
690
829
|
result.exitCode = exitCode;
|
|
691
830
|
progress.status = exitCode === 0 && !progress.error ? "completed" : "failed";
|
|
692
831
|
progress.durationMs = Date.now() - startTime;
|
|
832
|
+
|
|
833
|
+
// Push the terminal status to the live renderer so the TUI doesn't keep
|
|
834
|
+
// showing "running" after the child has exited. Pass exitCode so callers
|
|
835
|
+
// that hold a live result object (the subagent tool) can sync its exitCode
|
|
836
|
+
// and render the correct ✓/✗ icon instead of the -1 placeholder.
|
|
837
|
+
onUpdate?.(progress, result.usage, exitCode);
|
|
838
|
+
|
|
693
839
|
if (progress.error) result.output = result.output || `Error: ${progress.error}`;
|
|
694
840
|
|
|
695
841
|
// Truncate output if very large
|
|
@@ -900,6 +1046,10 @@ function renderAgentProgress(
|
|
|
900
1046
|
addLine(theme.fg("error", `Error: ${prog.error}`));
|
|
901
1047
|
}
|
|
902
1048
|
|
|
1049
|
+
if (prog.warning) {
|
|
1050
|
+
addLine(theme.fg("warning", `Warning: ${prog.warning}`));
|
|
1051
|
+
}
|
|
1052
|
+
|
|
903
1053
|
return c;
|
|
904
1054
|
}
|
|
905
1055
|
|
|
@@ -959,10 +1109,13 @@ async function runPipeline(
|
|
|
959
1109
|
totalUsage = accumulateUsage(totalUsage, result.usage);
|
|
960
1110
|
previousOutput = result.output;
|
|
961
1111
|
|
|
962
|
-
// Stop on error
|
|
1112
|
+
// Stop on error — surface the failing step's error as finalOutput (the pipeline
|
|
1113
|
+
// tool returns finalOutput as content, so the main LLM sees the actual failure,
|
|
1114
|
+
// not the previous step's success text).
|
|
963
1115
|
if (result.exitCode !== 0 || result.progress.error) {
|
|
1116
|
+
const errorDetail = buildPipelineErrorContent(i, step.agent, result);
|
|
964
1117
|
return {
|
|
965
|
-
steps: results, finalOutput:
|
|
1118
|
+
steps: results, finalOutput: errorDetail,
|
|
966
1119
|
stoppedAt: i, error: result.progress.error || `Agent ${step.agent} exited with code ${result.exitCode}`,
|
|
967
1120
|
totalUsage, totalDurationMs: Date.now() - startTime,
|
|
968
1121
|
};
|
|
@@ -1014,7 +1167,7 @@ async function runLoop(
|
|
|
1014
1167
|
}
|
|
1015
1168
|
}
|
|
1016
1169
|
const contextBlock = keptOutputs.join("\n\n");
|
|
1017
|
-
fullTask = `${task}\n\n
|
|
1170
|
+
fullTask = `${task}\n\n${LOOP_PRIOR_ITERATIONS_HEADER}\n${contextBlock}`;
|
|
1018
1171
|
}
|
|
1019
1172
|
|
|
1020
1173
|
const iterStart = Date.now();
|
|
@@ -1062,8 +1215,9 @@ async function runLoop(
|
|
|
1062
1215
|
|
|
1063
1216
|
if (result.exitCode !== 0 || result.progress.error) {
|
|
1064
1217
|
stoppedBecause = "error";
|
|
1218
|
+
const errorDetail = buildLoopErrorContent(i, agentName, result);
|
|
1065
1219
|
return {
|
|
1066
|
-
iterations, finalOutput:
|
|
1220
|
+
iterations, finalOutput: errorDetail,
|
|
1067
1221
|
stoppedBecause, totalUsage, totalDurationMs: Date.now() - startTime,
|
|
1068
1222
|
};
|
|
1069
1223
|
}
|
|
@@ -1239,10 +1393,17 @@ function renderLoopResult(
|
|
|
1239
1393
|
// ── Extension ─────────────────────────────────────────────────────────
|
|
1240
1394
|
|
|
1241
1395
|
export default function (pi: ExtensionAPI) {
|
|
1242
|
-
|
|
1243
|
-
semaphore = new Semaphore(
|
|
1396
|
+
extensionConfig = loadConfig();
|
|
1397
|
+
semaphore = new Semaphore(extensionConfig.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY);
|
|
1244
1398
|
agents = loadAgents();
|
|
1245
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
|
+
|
|
1246
1407
|
// If spawned as a child by a parent subagent process, PI_SUBAGENT_ALLOWED
|
|
1247
1408
|
// pins which agents we're allowed to expose. Filter the registry now, before
|
|
1248
1409
|
// any tool description sees the agent list — the child LLM should not even
|
|
@@ -1262,6 +1423,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1262
1423
|
"Use subagent to delegate *reasoning and decisions*: codebase exploration (scout), web research (researcher), or isolated code changes (worker)",
|
|
1263
1424
|
"For multiple independent subagent tasks, emit multiple `subagent` tool calls in the same turn — they run in parallel automatically.",
|
|
1264
1425
|
"Subagents have NO context from the current conversation — include ALL necessary context in the task description",
|
|
1426
|
+
"When a subagent returns an error, read it carefully. For transient failures (timeout, API/network), retry once with the same task plus 'Previous attempt failed with: {error}'. For structural failures (wrong approach, missing context), simplify the task or switch agents. If it persists after retry, report to the user with the specific error.",
|
|
1265
1427
|
],
|
|
1266
1428
|
parameters: Type.Object({
|
|
1267
1429
|
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
@@ -1296,9 +1458,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1296
1458
|
};
|
|
1297
1459
|
|
|
1298
1460
|
const result = await semaphore.run(() =>
|
|
1299
|
-
runSubagent(agent, params.task!, params.cwd ?? cwd, signal, (progress, usage) => {
|
|
1461
|
+
runSubagent(agent, params.task!, params.cwd ?? cwd, signal, (progress, usage, finalExitCode) => {
|
|
1300
1462
|
liveResult.progress = progress;
|
|
1301
1463
|
liveResult.usage = { ...usage };
|
|
1464
|
+
if (finalExitCode !== undefined) liveResult.exitCode = finalExitCode;
|
|
1302
1465
|
onUpdate?.({
|
|
1303
1466
|
content: [{ type: "text", text: "(running...)" }],
|
|
1304
1467
|
details: { results: [liveResult] },
|
|
@@ -1308,8 +1471,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1308
1471
|
|
|
1309
1472
|
result.contextWindow = contextWindow;
|
|
1310
1473
|
const isError = result.exitCode !== 0 || !!result.progress.error;
|
|
1474
|
+
const contentText = isError
|
|
1475
|
+
? buildSubagentErrorContent(result)
|
|
1476
|
+
: (result.output || "(no output)");
|
|
1311
1477
|
return {
|
|
1312
|
-
content: [{ type: "text", text:
|
|
1478
|
+
content: [{ type: "text", text: contentText }],
|
|
1313
1479
|
details: { results: [result] },
|
|
1314
1480
|
...(isError ? { isError: true } : {}),
|
|
1315
1481
|
};
|
|
@@ -1403,7 +1569,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1403
1569
|
promptGuidelines: [
|
|
1404
1570
|
"Use pipeline when a task naturally decomposes into sequential agent roles (e.g. explore → plan → implement → review).",
|
|
1405
1571
|
"Each step receives the previous step's output automatically via {previous} placeholder substitution.",
|
|
1406
|
-
"Pipelines stop on first error. The finalOutput is the
|
|
1572
|
+
"Pipelines stop on first error. The finalOutput is the failing step's error detail.",
|
|
1573
|
+
"When a pipeline fails at a step, the error identifies which step and why. Retry the failing step with a simpler task, or re-scope the pipeline. Early-step (exploration) failures → retry the whole pipeline with a more focused scope.",
|
|
1407
1574
|
],
|
|
1408
1575
|
parameters: Type.Object({
|
|
1409
1576
|
steps: Type.Array(
|
|
@@ -1535,6 +1702,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1535
1702
|
"Use loop for tasks that benefit from iterative refinement (e.g. drafting → reviewing → polishing).",
|
|
1536
1703
|
"Configure a judge agent to stop early when quality is sufficient, avoiding wasted iterations.",
|
|
1537
1704
|
"Each iteration receives all prior outputs as context, enabling progressive improvement.",
|
|
1705
|
+
"When a loop iteration fails, the error shows which iteration. Reduce max_iterations or simplify the task; if the judge consistently rejects, refine the criteria or switch judge agent.",
|
|
1538
1706
|
],
|
|
1539
1707
|
parameters: Type.Object({
|
|
1540
1708
|
agent: Type.String({ description: "Agent name to run in the loop" }),
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error-message helpers for subagent tool returns.
|
|
3
|
+
*
|
|
4
|
+
* pi-agent-core drops the `isError` flag on normal tool returns, so `content`
|
|
5
|
+
* is the only reliable channel to communicate failure context back to the main
|
|
6
|
+
* LLM. These helpers build clear, actionable messages so the LLM can retry,
|
|
7
|
+
* redirect, or report.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Minimal shape the helper needs from a subagent result. */
|
|
11
|
+
interface SubagentErrorResult {
|
|
12
|
+
agent: string;
|
|
13
|
+
exitCode: number;
|
|
14
|
+
output: string;
|
|
15
|
+
progress: {
|
|
16
|
+
error?: string;
|
|
17
|
+
lastMessage?: string;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build a clear, actionable error message for the main LLM when a single
|
|
23
|
+
* subagent fails. The `content` channel is the only reliable one (pi-agent-core
|
|
24
|
+
* drops a returned `isError` flag on normal tool returns), so this must carry
|
|
25
|
+
* enough context for the LLM to retry, redirect, or report.
|
|
26
|
+
*/
|
|
27
|
+
export function buildSubagentErrorContent(result: SubagentErrorResult): string {
|
|
28
|
+
const parts: string[] = [];
|
|
29
|
+
parts.push(`[Subagent error] Agent "${result.agent}" failed (exit code ${result.exitCode}).`);
|
|
30
|
+
if (result.progress.error) {
|
|
31
|
+
parts.push(`Error: ${result.progress.error}`);
|
|
32
|
+
}
|
|
33
|
+
if (result.progress.lastMessage) {
|
|
34
|
+
parts.push(`Last message: ${result.progress.lastMessage}`);
|
|
35
|
+
}
|
|
36
|
+
if (result.output && result.output !== "(no output)") {
|
|
37
|
+
parts.push(`Output:\n${result.output}`);
|
|
38
|
+
}
|
|
39
|
+
return parts.join("\n");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildPipelineErrorContent(
|
|
43
|
+
stepIndex: number,
|
|
44
|
+
stepAgent: string,
|
|
45
|
+
result: { exitCode: number; output: string; progress: { error?: string } },
|
|
46
|
+
): string {
|
|
47
|
+
return [
|
|
48
|
+
`Pipeline failed at step ${stepIndex + 1} (agent: ${stepAgent}).`,
|
|
49
|
+
`Exit code: ${result.exitCode}`,
|
|
50
|
+
result.progress.error ? `Error: ${result.progress.error}` : null,
|
|
51
|
+
result.output ? `Output:\n${result.output}` : "(no output)",
|
|
52
|
+
].filter(Boolean).join("\n");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function buildLoopErrorContent(
|
|
56
|
+
iteration: number,
|
|
57
|
+
agentName: string,
|
|
58
|
+
result: { exitCode: number; output: string; progress: { error?: string } },
|
|
59
|
+
): string {
|
|
60
|
+
return [
|
|
61
|
+
`Loop failed at iteration ${iteration + 1} (agent: ${agentName}).`,
|
|
62
|
+
`Exit code: ${result.exitCode}`,
|
|
63
|
+
result.progress.error ? `Error: ${result.progress.error}` : null,
|
|
64
|
+
result.output ? `Output:\n${result.output}` : "(no output)",
|
|
65
|
+
].filter(Boolean).join("\n");
|
|
66
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cycle detector for tool-call streams. Watches a sliding window of
|
|
3
|
+
* tool-call signatures for a sub-sequence of length P repeated 3 times
|
|
4
|
+
* (P in [2,8]). Catches an agent stuck in a tool-call loop without
|
|
5
|
+
* imposing any count cap on legitimate work. Purely parent-side.
|
|
6
|
+
*/
|
|
7
|
+
const WINDOW_SIZE = 24;
|
|
8
|
+
const MIN_PATTERN_LEN = 2;
|
|
9
|
+
const MAX_PATTERN_LEN = 8;
|
|
10
|
+
const REPETITIONS = 3;
|
|
11
|
+
|
|
12
|
+
export interface CycleResult {
|
|
13
|
+
cycle: boolean;
|
|
14
|
+
pattern?: string[]; // the repeating sub-sequence of signatures (length P)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Detect whether `newSig` completes a 3× cycle. Pure: does NOT mutate `history`.
|
|
19
|
+
* The caller pushes `newSig` onto its own history AFTER calling this.
|
|
20
|
+
*/
|
|
21
|
+
export function detectCycle(history: string[], newSig: string): CycleResult {
|
|
22
|
+
const window = [...history, newSig];
|
|
23
|
+
const start = Math.max(0, window.length - WINDOW_SIZE);
|
|
24
|
+
const w = window.slice(start);
|
|
25
|
+
for (let p = MIN_PATTERN_LEN; p <= MAX_PATTERN_LEN; p++) {
|
|
26
|
+
const needed = p * REPETITIONS;
|
|
27
|
+
if (w.length < needed) continue;
|
|
28
|
+
const tail = w.slice(-needed);
|
|
29
|
+
const pattern = tail.slice(0, p);
|
|
30
|
+
let isCycle = true;
|
|
31
|
+
for (let i = 0; i < p; i++) {
|
|
32
|
+
if (pattern[i] !== tail[p + i] || pattern[i] !== tail[2 * p + i]) {
|
|
33
|
+
isCycle = false;
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (isCycle) return { cycle: true, pattern };
|
|
38
|
+
}
|
|
39
|
+
return { cycle: false };
|
|
40
|
+
}
|
package/package.json
CHANGED
|
@@ -1,42 +1,42 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
2
|
+
"name": "@rohaquinlop/pi-subagents",
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "Pi extension for delegating tasks to subagents — parallel execution, agent discovery, and TUI rendering",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"subagent",
|
|
8
|
+
"parallel",
|
|
9
|
+
"coding-agent",
|
|
10
|
+
"extension"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "rohaquinlop",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/rohaquinlop/pi-subagents.git"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"index.ts",
|
|
20
|
+
"agents/",
|
|
21
|
+
"tools/",
|
|
22
|
+
"lib/",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"pi": {
|
|
26
|
+
"extensions": [
|
|
27
|
+
"./index.ts"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
32
|
+
"@earendil-works/pi-tui": "*",
|
|
33
|
+
"@sinclair/typebox": "*"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:watch": "vitest"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"vitest": "^3.2.0"
|
|
41
|
+
}
|
|
42
42
|
}
|
package/tools/safe-bash.ts
CHANGED
|
@@ -6,6 +6,8 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
6
6
|
import { createBashTool } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { Type } from "@sinclair/typebox";
|
|
8
8
|
|
|
9
|
+
const DEFAULT_SAFE_BASH_TIMEOUT_S = 300; // 5 minutes — kills stuck commands instead of relying on the 10-min wall-clock
|
|
10
|
+
|
|
9
11
|
const DANGEROUS_PATTERNS = [
|
|
10
12
|
/\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?(-[a-zA-Z]*r[a-zA-Z]*\s+)?(\/|~\/?\s|~\/?\b)/,
|
|
11
13
|
/\brm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+)?(-[a-zA-Z]*f[a-zA-Z]*\s+)?(\/|~\/?\s|~\/?\b)/,
|
|
@@ -46,7 +48,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
46
48
|
parameters: Type.Object({
|
|
47
49
|
command: Type.String({ description: "Bash command to execute" }),
|
|
48
50
|
timeout: Type.Optional(
|
|
49
|
-
Type.Number({ description: "Timeout in seconds (
|
|
51
|
+
Type.Number({ description: "Timeout in seconds (default: 300s/5min; pass a larger value for builds/tests/installs)" }),
|
|
50
52
|
),
|
|
51
53
|
}),
|
|
52
54
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
@@ -54,7 +56,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
54
56
|
if (danger) {
|
|
55
57
|
throw new Error(danger);
|
|
56
58
|
}
|
|
57
|
-
|
|
59
|
+
const timeout = params.timeout ?? DEFAULT_SAFE_BASH_TIMEOUT_S;
|
|
60
|
+
return bashTool.execute(toolCallId, { ...params, timeout }, signal, onUpdate);
|
|
58
61
|
},
|
|
59
62
|
});
|
|
60
63
|
}
|