@pify/swarm 0.7.7 → 0.8.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.
@@ -26,6 +26,7 @@ import { Type } from "typebox";
26
26
 
27
27
  import { BUILTIN_AGENTS } from "../src/builtin.ts";
28
28
  import { withUiLock } from "../src/ui-lock.ts";
29
+ import { LoopGuard } from "../src/loop-guard.ts";
29
30
  import {
30
31
  consentQuestion,
31
32
  decideConsent,
@@ -183,6 +184,7 @@ export default function swarm(pi: ExtensionAPI) {
183
184
  let session: AgentSession | null = null;
184
185
  let unsubscribe: (() => void) | null = null;
185
186
  let releaseLive: (() => void) | null = null;
187
+ let stallReason: string | null = null;
186
188
  try {
187
189
  let model = ctx.model ?? null;
188
190
  if (def.model) {
@@ -230,11 +232,37 @@ export default function swarm(pi: ExtensionAPI) {
230
232
  session = created.session;
231
233
  releaseLive = live.register(runId, session);
232
234
 
235
+ const guard = new LoopGuard();
233
236
  unsubscribe = session.subscribe((event) => {
234
- if (event.type === "message_end" && (event as { message?: { role?: string } }).message?.role === "assistant") {
237
+ const message = (
238
+ event as {
239
+ message?: {
240
+ role?: string;
241
+ usage?: { totalTokens?: number };
242
+ content?: Array<{ type?: string; text?: string }>;
243
+ };
244
+ }
245
+ ).message;
246
+ if (event.type === "message_end" && message?.role === "assistant") {
235
247
  item.turns++;
236
- const usage = (event as { message?: { usage?: { totalTokens?: number } } }).message?.usage;
248
+ const usage = message.usage;
237
249
  if (usage && typeof usage.totalTokens === "number") item.tokens += usage.totalTokens;
250
+
251
+ // Stop an item that is spinning — restating itself without acting —
252
+ // rather than letting it run to the turn cap. See loop-guard.ts.
253
+ if (!stallReason && Array.isArray(message.content)) {
254
+ const usedTool = message.content.some((c) => c.type === "toolCall");
255
+ const turnText = message.content
256
+ .filter((c) => c.type === "text" && typeof c.text === "string")
257
+ .map((c) => c.text)
258
+ .join("\n");
259
+ const verdict = guard.observe({ text: turnText, usedTool });
260
+ if (verdict.stalled) {
261
+ stallReason = verdict.reason ?? "no progress";
262
+ void session?.abort().catch(() => {});
263
+ }
264
+ }
265
+
238
266
  renderWidget();
239
267
  if (item.turns >= def.maxTurns) void session?.abort().catch(() => {});
240
268
  }
@@ -255,9 +283,18 @@ export default function swarm(pi: ExtensionAPI) {
255
283
  .join("\n")
256
284
  .trim();
257
285
 
258
- item.result = text || null;
259
- item.status =
260
- last?.stopReason === "aborted" ? "aborted" : last?.stopReason === "error" ? "error" : "done";
286
+ // An item the loop guard stopped gave up rather than concluded — mark it
287
+ // so the aggregated report does not read it as a finished answer.
288
+ item.result = stallReason
289
+ ? `${text ? `${text}\n\n` : ""}[stopped: no progress — the agent ${stallReason}]`
290
+ : text || null;
291
+ item.status = stallReason
292
+ ? "aborted"
293
+ : last?.stopReason === "aborted"
294
+ ? "aborted"
295
+ : last?.stopReason === "error"
296
+ ? "error"
297
+ : "done";
261
298
  if (item.status === "error") item.error = text || "child session error";
262
299
  // A child that stopped cleanly and said nothing has not answered — the
263
300
  // fix subagent already carries and this executor never received. Left
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pify/swarm",
3
- "version": "0.7.7",
3
+ "version": "0.8.0",
4
4
  "description": "Coordinate multiple pi agents in parallel: swarm_run fan-out with per-item auto-routing, concurrency queue, aggregated reports",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Stop a child that has stopped making progress.
3
+ *
4
+ * A turn cap bounds what a runaway child can cost; it does not notice the
5
+ * characteristic autonomous-agent failure, which is cheaper per turn and just
6
+ * as stuck: restating the same plan every turn without calling a tool, or
7
+ * oscillating between two states forever. pi's own loop will happily let that
8
+ * run to the cap.
9
+ *
10
+ * So fingerprint each assistant turn and watch for two shapes. A turn that
11
+ * called a tool is progress and clears the history — the guard only fires on
12
+ * turns that did nothing but talk. `repeat` identical tool-free turns is a
13
+ * child spinning in place; an A-B-A-B… run of `cycle` cycles is one bouncing
14
+ * between two dead ends. Both are advisory signals the caller acts on (a
15
+ * spawned child is unattended, so acting means aborting it).
16
+ *
17
+ * Zero dependencies — node:crypto for the hash. Deterministic and pure given
18
+ * the sequence of turns, so it is unit-testable without a live child.
19
+ */
20
+ import { createHash } from "node:crypto";
21
+
22
+ export interface LoopGuardConfig {
23
+ /** Consecutive identical tool-free turns before flagging a stall (min 2, default 3). */
24
+ repeat?: number;
25
+ /** Repeats of a two-turn A-B cycle before flagging (min 2, default 3). */
26
+ cycle?: number;
27
+ }
28
+
29
+ export interface Turn {
30
+ /** The visible assistant text of the turn. */
31
+ text: string;
32
+ /** Did the turn invoke at least one tool? A tool call is progress. */
33
+ usedTool: boolean;
34
+ }
35
+
36
+ export interface LoopVerdict {
37
+ stalled: boolean;
38
+ reason?: string;
39
+ }
40
+
41
+ /** Fold away cosmetic differences so "the same thought" hashes the same. */
42
+ function fingerprint(text: string): string {
43
+ const norm = text.normalize("NFKC").toLowerCase().replace(/\s+/g, " ").trim();
44
+ return createHash("sha256").update(norm).digest("hex");
45
+ }
46
+
47
+ export class LoopGuard {
48
+ private readonly repeat: number;
49
+ private readonly cycle: number;
50
+ /** Recent tool-free fingerprints; a tool call clears this. */
51
+ private readonly recent: string[] = [];
52
+ private static readonly MAX = 16;
53
+
54
+ constructor(cfg: LoopGuardConfig = {}) {
55
+ this.repeat = Math.max(2, cfg.repeat ?? 3);
56
+ this.cycle = Math.max(2, cfg.cycle ?? 3);
57
+ }
58
+
59
+ observe(turn: Turn): LoopVerdict {
60
+ // A tool call is forward motion: forget the stall history entirely.
61
+ if (turn.usedTool) {
62
+ this.recent.length = 0;
63
+ return { stalled: false };
64
+ }
65
+ // A silent turn (no text, no tool) is not evidence of a loop by itself.
66
+ if (turn.text.trim() === "") return { stalled: false };
67
+
68
+ const fp = fingerprint(turn.text);
69
+ this.recent.push(fp);
70
+ if (this.recent.length > LoopGuard.MAX) this.recent.shift();
71
+
72
+ // Spinning in place: the last `repeat` tool-free turns are identical.
73
+ if (this.recent.length >= this.repeat && this.recent.slice(-this.repeat).every((f) => f === fp)) {
74
+ return { stalled: true, reason: `repeated the same output for ${this.repeat} turns without acting` };
75
+ }
76
+
77
+ // Oscillating: the last 2×cycle turns are a strict A-B-A-B… alternation.
78
+ const need = 2 * this.cycle;
79
+ if (this.recent.length >= need) {
80
+ const tail = this.recent.slice(-need);
81
+ const a = tail[0]!;
82
+ const b = tail[1]!;
83
+ if (a !== b && tail.every((f, i) => f === (i % 2 === 0 ? a : b))) {
84
+ return { stalled: true, reason: `oscillated between two states for ${this.cycle} cycles without acting` };
85
+ }
86
+ }
87
+
88
+ return { stalled: false };
89
+ }
90
+ }