@rohaquinlop/pi-subagents 0.4.0 → 0.5.0
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 +156 -17
- 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,8 @@ 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";
|
|
20
22
|
|
|
21
23
|
interface ToolEvent {
|
|
22
24
|
tool: string;
|
|
@@ -55,6 +57,7 @@ interface AgentProgress {
|
|
|
55
57
|
durationMs: number;
|
|
56
58
|
lastMessage: string;
|
|
57
59
|
error?: string;
|
|
60
|
+
warning?: string;
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
interface AgentResult {
|
|
@@ -78,6 +81,8 @@ interface Details {
|
|
|
78
81
|
|
|
79
82
|
interface ExtensionConfig {
|
|
80
83
|
maxConcurrency?: number;
|
|
84
|
+
subagentTimeoutMs?: number; // wall-clock, default 600000 (10 min). 0 = disabled.
|
|
85
|
+
subagentIdleTimeoutMs?: number; // no-stdout watchdog, default 300000 (5 min). 0 = disabled.
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
const EXT_DIR = path.dirname(new URL(import.meta.url).pathname);
|
|
@@ -85,6 +90,10 @@ const AGENTS_DIR = path.join(EXT_DIR, "agents");
|
|
|
85
90
|
const TOOLS_DIR = path.join(EXT_DIR, "tools");
|
|
86
91
|
const CONFIG_PATH = path.join(EXT_DIR, "config.json");
|
|
87
92
|
const DEFAULT_MAX_CONCURRENCY = 4;
|
|
93
|
+
const DEFAULT_SUBAGENT_TIMEOUT_MS = 600_000; // 10 minutes
|
|
94
|
+
const DEFAULT_SUBAGENT_IDLE_TIMEOUT_MS = 300_000; // 5 minutes
|
|
95
|
+
|
|
96
|
+
let extensionConfig: ExtensionConfig = {};
|
|
88
97
|
|
|
89
98
|
function loadConfig(): ExtensionConfig {
|
|
90
99
|
try {
|
|
@@ -479,6 +488,11 @@ function flatten(s: string): string {
|
|
|
479
488
|
// doesn't need to read inline anyway.
|
|
480
489
|
const MAX_ARG_PREVIEW = 4000;
|
|
481
490
|
|
|
491
|
+
// Hard cap on recentTools entries to prevent unbounded memory growth in
|
|
492
|
+
// long-running subagents. Generous for expanded-view history; matches the
|
|
493
|
+
// callHistory trim pattern.
|
|
494
|
+
const MAX_RECENT_TOOLS = 50;
|
|
495
|
+
|
|
482
496
|
function extractToolArgsPreview(args: Record<string, unknown>): string {
|
|
483
497
|
const cap = (s: string) => (s.length > MAX_ARG_PREVIEW ? s.slice(0, MAX_ARG_PREVIEW) + "…" : s);
|
|
484
498
|
if (args.command) return cap(flatten(String(args.command)));
|
|
@@ -502,7 +516,7 @@ async function runSubagent(
|
|
|
502
516
|
task: string,
|
|
503
517
|
cwd: string,
|
|
504
518
|
signal: AbortSignal | undefined,
|
|
505
|
-
onUpdate?: (progress: AgentProgress, usage: AgentResult["usage"]) => void,
|
|
519
|
+
onUpdate?: (progress: AgentProgress, usage: AgentResult["usage"], finalExitCode?: number) => void,
|
|
506
520
|
): Promise<AgentResult> {
|
|
507
521
|
const { args, tempDir, childEnv } = await buildPiArgs(agent, task, cwd);
|
|
508
522
|
const command = args[0];
|
|
@@ -544,14 +558,73 @@ async function runSubagent(
|
|
|
544
558
|
|
|
545
559
|
let buf = "";
|
|
546
560
|
let stderrBuf = "";
|
|
561
|
+
let resolved = false;
|
|
562
|
+
let wallTimer: ReturnType<typeof setTimeout> | undefined;
|
|
563
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
564
|
+
let inFlightToolCount = 0;
|
|
565
|
+
let childClosed = false;
|
|
566
|
+
let sigkillTimer: ReturnType<typeof setTimeout> | undefined;
|
|
567
|
+
let callHistory: string[] = []; // sliding window of tool-call signatures for cycle detection
|
|
568
|
+
|
|
569
|
+
const safeResolve = (code: number) => {
|
|
570
|
+
if (resolved) return;
|
|
571
|
+
resolved = true;
|
|
572
|
+
clearTimeout(wallTimer);
|
|
573
|
+
clearTimeout(idleTimer);
|
|
574
|
+
clearTimeout(sigkillTimer);
|
|
575
|
+
if (signal) signal.removeEventListener("abort", abortKill);
|
|
576
|
+
resolve(code);
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
// Wall-clock timeout
|
|
580
|
+
const timeoutMs = extensionConfig.subagentTimeoutMs ?? DEFAULT_SUBAGENT_TIMEOUT_MS;
|
|
581
|
+
if (timeoutMs > 0) {
|
|
582
|
+
wallTimer = setTimeout(() => {
|
|
583
|
+
if (!progress.error) progress.error = `Subagent timed out after ${Math.round(timeoutMs / 1000)}s`;
|
|
584
|
+
proc.kill("SIGTERM");
|
|
585
|
+
clearTimeout(sigkillTimer);
|
|
586
|
+
sigkillTimer = setTimeout(() => {
|
|
587
|
+
if (!childClosed) proc.kill("SIGKILL");
|
|
588
|
+
}, 5000);
|
|
589
|
+
}, timeoutMs);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Idle (no-stdout) watchdog
|
|
593
|
+
const idleMs = extensionConfig.subagentIdleTimeoutMs ?? DEFAULT_SUBAGENT_IDLE_TIMEOUT_MS;
|
|
594
|
+
const resetIdle = () => {
|
|
595
|
+
if (idleMs <= 0) return;
|
|
596
|
+
clearTimeout(idleTimer);
|
|
597
|
+
idleTimer = setTimeout(() => {
|
|
598
|
+
if (!progress.error) progress.error = `Subagent idle for ${Math.round(idleMs / 1000)}s — likely stuck`;
|
|
599
|
+
proc.kill("SIGTERM");
|
|
600
|
+
clearTimeout(sigkillTimer);
|
|
601
|
+
sigkillTimer = setTimeout(() => {
|
|
602
|
+
if (!childClosed) proc.kill("SIGKILL");
|
|
603
|
+
}, 5000);
|
|
604
|
+
}, idleMs);
|
|
605
|
+
};
|
|
606
|
+
resetIdle();
|
|
607
|
+
|
|
608
|
+
const pauseIdle = () => {
|
|
609
|
+
clearTimeout(idleTimer);
|
|
610
|
+
idleTimer = undefined;
|
|
611
|
+
};
|
|
612
|
+
const resumeIdle = () => {
|
|
613
|
+
if (inFlightToolCount === 0) resetIdle();
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
const MAX_STDERR_BYTES = 100_000;
|
|
547
617
|
|
|
548
618
|
const processLine = (line: string) => {
|
|
619
|
+
if (inFlightToolCount === 0) resetIdle();
|
|
549
620
|
if (!line.trim()) return;
|
|
550
621
|
try {
|
|
551
622
|
const evt = JSON.parse(line) as any;
|
|
552
623
|
progress.durationMs = Date.now() - startTime;
|
|
553
624
|
|
|
554
625
|
if (evt.type === "tool_execution_start") {
|
|
626
|
+
inFlightToolCount++;
|
|
627
|
+
pauseIdle();
|
|
555
628
|
progress.toolCount++;
|
|
556
629
|
progress.recentTools.push({
|
|
557
630
|
tool: evt.toolName,
|
|
@@ -559,6 +632,37 @@ async function runSubagent(
|
|
|
559
632
|
toolCallId: evt.toolCallId,
|
|
560
633
|
status: "running",
|
|
561
634
|
});
|
|
635
|
+
// Trim oldest completed entries, but never evict an in-flight tool —
|
|
636
|
+
// otherwise tool_execution_end's .find(toolCallId) would no-op and leave a
|
|
637
|
+
// permanently-"running" ghost.
|
|
638
|
+
while (progress.recentTools.length > MAX_RECENT_TOOLS) {
|
|
639
|
+
const idx = progress.recentTools.findIndex((t) => t.status !== "running");
|
|
640
|
+
if (idx === -1) break; // only running entries left — don't evict in-flight
|
|
641
|
+
progress.recentTools.splice(idx, 1);
|
|
642
|
+
}
|
|
643
|
+
// ── Cycle detection (parent-side, context-free) ──
|
|
644
|
+
// Signature = toolName + args preview. Two calls with different args
|
|
645
|
+
// (different file, or same file different content) → different sig.
|
|
646
|
+
const sig = `${evt.toolName}:${extractToolArgsPreview((evt.args || {}) as Record<string, unknown>)}`;
|
|
647
|
+
const cycleResult = detectCycle(callHistory, sig);
|
|
648
|
+
callHistory.push(sig);
|
|
649
|
+
if (callHistory.length > 24) callHistory = callHistory.slice(-24);
|
|
650
|
+
|
|
651
|
+
if (cycleResult.cycle) {
|
|
652
|
+
const toolNames = (cycleResult.pattern || []).map((s) => {
|
|
653
|
+
const colonIdx = s.indexOf(":");
|
|
654
|
+
return colonIdx >= 0 ? s.slice(0, colonIdx) : s;
|
|
655
|
+
});
|
|
656
|
+
const patternStr = toolNames.join("→");
|
|
657
|
+
if (!progress.error) {
|
|
658
|
+
progress.error = `Subagent stuck in a tool-call loop: repeating ${patternStr}`;
|
|
659
|
+
}
|
|
660
|
+
proc.kill("SIGTERM");
|
|
661
|
+
clearTimeout(sigkillTimer);
|
|
662
|
+
sigkillTimer = setTimeout(() => {
|
|
663
|
+
if (!childClosed) proc.kill("SIGKILL");
|
|
664
|
+
}, 5000);
|
|
665
|
+
}
|
|
562
666
|
fireUpdate();
|
|
563
667
|
}
|
|
564
668
|
|
|
@@ -593,6 +697,8 @@ async function runSubagent(
|
|
|
593
697
|
hit.children = finalChildren as AgentResult[];
|
|
594
698
|
}
|
|
595
699
|
}
|
|
700
|
+
inFlightToolCount = Math.max(0, inFlightToolCount - 1);
|
|
701
|
+
resumeIdle();
|
|
596
702
|
fireUpdate();
|
|
597
703
|
}
|
|
598
704
|
|
|
@@ -659,26 +765,37 @@ async function runSubagent(
|
|
|
659
765
|
});
|
|
660
766
|
|
|
661
767
|
proc.stderr.on("data", (d: Buffer) => {
|
|
768
|
+
if (stderrBuf.length >= MAX_STDERR_BYTES) return;
|
|
662
769
|
stderrBuf += d.toString();
|
|
770
|
+
if (stderrBuf.length >= MAX_STDERR_BYTES) {
|
|
771
|
+
stderrBuf = stderrBuf.slice(0, MAX_STDERR_BYTES) + "\n[stderr truncated]";
|
|
772
|
+
}
|
|
663
773
|
});
|
|
664
774
|
|
|
665
775
|
proc.on("close", (code) => {
|
|
776
|
+
childClosed = true;
|
|
666
777
|
if (buf.trim()) processLine(buf);
|
|
667
778
|
if (code !== 0 && stderrBuf.trim() && !progress.error) {
|
|
668
779
|
progress.error = stderrBuf.trim();
|
|
780
|
+
} else if (code === 0 && stderrBuf.trim()) {
|
|
781
|
+
// Non-fatal: surface stderr (e.g. deprecation warnings) on a successful exit.
|
|
782
|
+
progress.warning = stderrBuf.trim().slice(0, 2000);
|
|
669
783
|
}
|
|
670
|
-
|
|
784
|
+
safeResolve(code ?? 1);
|
|
671
785
|
});
|
|
672
786
|
|
|
673
|
-
proc.on("error", () =>
|
|
787
|
+
proc.on("error", () => safeResolve(1));
|
|
674
788
|
|
|
789
|
+
const abortKill = () => {
|
|
790
|
+
proc.kill("SIGTERM");
|
|
791
|
+
clearTimeout(sigkillTimer);
|
|
792
|
+
sigkillTimer = setTimeout(() => {
|
|
793
|
+
if (!childClosed) proc.kill("SIGKILL");
|
|
794
|
+
}, 3000);
|
|
795
|
+
};
|
|
675
796
|
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 });
|
|
797
|
+
if (signal.aborted) abortKill();
|
|
798
|
+
else signal.addEventListener("abort", abortKill, { once: true });
|
|
682
799
|
}
|
|
683
800
|
});
|
|
684
801
|
|
|
@@ -690,6 +807,13 @@ async function runSubagent(
|
|
|
690
807
|
result.exitCode = exitCode;
|
|
691
808
|
progress.status = exitCode === 0 && !progress.error ? "completed" : "failed";
|
|
692
809
|
progress.durationMs = Date.now() - startTime;
|
|
810
|
+
|
|
811
|
+
// Push the terminal status to the live renderer so the TUI doesn't keep
|
|
812
|
+
// showing "running" after the child has exited. Pass exitCode so callers
|
|
813
|
+
// that hold a live result object (the subagent tool) can sync its exitCode
|
|
814
|
+
// and render the correct ✓/✗ icon instead of the -1 placeholder.
|
|
815
|
+
onUpdate?.(progress, result.usage, exitCode);
|
|
816
|
+
|
|
693
817
|
if (progress.error) result.output = result.output || `Error: ${progress.error}`;
|
|
694
818
|
|
|
695
819
|
// Truncate output if very large
|
|
@@ -900,6 +1024,10 @@ function renderAgentProgress(
|
|
|
900
1024
|
addLine(theme.fg("error", `Error: ${prog.error}`));
|
|
901
1025
|
}
|
|
902
1026
|
|
|
1027
|
+
if (prog.warning) {
|
|
1028
|
+
addLine(theme.fg("warning", `Warning: ${prog.warning}`));
|
|
1029
|
+
}
|
|
1030
|
+
|
|
903
1031
|
return c;
|
|
904
1032
|
}
|
|
905
1033
|
|
|
@@ -959,10 +1087,13 @@ async function runPipeline(
|
|
|
959
1087
|
totalUsage = accumulateUsage(totalUsage, result.usage);
|
|
960
1088
|
previousOutput = result.output;
|
|
961
1089
|
|
|
962
|
-
// Stop on error
|
|
1090
|
+
// Stop on error — surface the failing step's error as finalOutput (the pipeline
|
|
1091
|
+
// tool returns finalOutput as content, so the main LLM sees the actual failure,
|
|
1092
|
+
// not the previous step's success text).
|
|
963
1093
|
if (result.exitCode !== 0 || result.progress.error) {
|
|
1094
|
+
const errorDetail = buildPipelineErrorContent(i, step.agent, result);
|
|
964
1095
|
return {
|
|
965
|
-
steps: results, finalOutput:
|
|
1096
|
+
steps: results, finalOutput: errorDetail,
|
|
966
1097
|
stoppedAt: i, error: result.progress.error || `Agent ${step.agent} exited with code ${result.exitCode}`,
|
|
967
1098
|
totalUsage, totalDurationMs: Date.now() - startTime,
|
|
968
1099
|
};
|
|
@@ -1062,8 +1193,9 @@ async function runLoop(
|
|
|
1062
1193
|
|
|
1063
1194
|
if (result.exitCode !== 0 || result.progress.error) {
|
|
1064
1195
|
stoppedBecause = "error";
|
|
1196
|
+
const errorDetail = buildLoopErrorContent(i, agentName, result);
|
|
1065
1197
|
return {
|
|
1066
|
-
iterations, finalOutput:
|
|
1198
|
+
iterations, finalOutput: errorDetail,
|
|
1067
1199
|
stoppedBecause, totalUsage, totalDurationMs: Date.now() - startTime,
|
|
1068
1200
|
};
|
|
1069
1201
|
}
|
|
@@ -1239,8 +1371,8 @@ function renderLoopResult(
|
|
|
1239
1371
|
// ── Extension ─────────────────────────────────────────────────────────
|
|
1240
1372
|
|
|
1241
1373
|
export default function (pi: ExtensionAPI) {
|
|
1242
|
-
|
|
1243
|
-
semaphore = new Semaphore(
|
|
1374
|
+
extensionConfig = loadConfig();
|
|
1375
|
+
semaphore = new Semaphore(extensionConfig.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY);
|
|
1244
1376
|
agents = loadAgents();
|
|
1245
1377
|
|
|
1246
1378
|
// If spawned as a child by a parent subagent process, PI_SUBAGENT_ALLOWED
|
|
@@ -1262,6 +1394,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1262
1394
|
"Use subagent to delegate *reasoning and decisions*: codebase exploration (scout), web research (researcher), or isolated code changes (worker)",
|
|
1263
1395
|
"For multiple independent subagent tasks, emit multiple `subagent` tool calls in the same turn — they run in parallel automatically.",
|
|
1264
1396
|
"Subagents have NO context from the current conversation — include ALL necessary context in the task description",
|
|
1397
|
+
"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
1398
|
],
|
|
1266
1399
|
parameters: Type.Object({
|
|
1267
1400
|
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
@@ -1296,9 +1429,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1296
1429
|
};
|
|
1297
1430
|
|
|
1298
1431
|
const result = await semaphore.run(() =>
|
|
1299
|
-
runSubagent(agent, params.task!, params.cwd ?? cwd, signal, (progress, usage) => {
|
|
1432
|
+
runSubagent(agent, params.task!, params.cwd ?? cwd, signal, (progress, usage, finalExitCode) => {
|
|
1300
1433
|
liveResult.progress = progress;
|
|
1301
1434
|
liveResult.usage = { ...usage };
|
|
1435
|
+
if (finalExitCode !== undefined) liveResult.exitCode = finalExitCode;
|
|
1302
1436
|
onUpdate?.({
|
|
1303
1437
|
content: [{ type: "text", text: "(running...)" }],
|
|
1304
1438
|
details: { results: [liveResult] },
|
|
@@ -1308,8 +1442,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1308
1442
|
|
|
1309
1443
|
result.contextWindow = contextWindow;
|
|
1310
1444
|
const isError = result.exitCode !== 0 || !!result.progress.error;
|
|
1445
|
+
const contentText = isError
|
|
1446
|
+
? buildSubagentErrorContent(result)
|
|
1447
|
+
: (result.output || "(no output)");
|
|
1311
1448
|
return {
|
|
1312
|
-
content: [{ type: "text", text:
|
|
1449
|
+
content: [{ type: "text", text: contentText }],
|
|
1313
1450
|
details: { results: [result] },
|
|
1314
1451
|
...(isError ? { isError: true } : {}),
|
|
1315
1452
|
};
|
|
@@ -1403,7 +1540,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1403
1540
|
promptGuidelines: [
|
|
1404
1541
|
"Use pipeline when a task naturally decomposes into sequential agent roles (e.g. explore → plan → implement → review).",
|
|
1405
1542
|
"Each step receives the previous step's output automatically via {previous} placeholder substitution.",
|
|
1406
|
-
"Pipelines stop on first error. The finalOutput is the
|
|
1543
|
+
"Pipelines stop on first error. The finalOutput is the failing step's error detail.",
|
|
1544
|
+
"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
1545
|
],
|
|
1408
1546
|
parameters: Type.Object({
|
|
1409
1547
|
steps: Type.Array(
|
|
@@ -1535,6 +1673,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1535
1673
|
"Use loop for tasks that benefit from iterative refinement (e.g. drafting → reviewing → polishing).",
|
|
1536
1674
|
"Configure a judge agent to stop early when quality is sufficient, avoiding wasted iterations.",
|
|
1537
1675
|
"Each iteration receives all prior outputs as context, enabling progressive improvement.",
|
|
1676
|
+
"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
1677
|
],
|
|
1539
1678
|
parameters: Type.Object({
|
|
1540
1679
|
agent: Type.String({ description: "Agent name to run in the loop" }),
|
|
@@ -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.0",
|
|
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
|
}
|