@pify/swarm 0.7.6 → 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.
- package/README.md +1 -1
- package/extensions/swarm.ts +42 -5
- package/package.json +1 -1
- package/src/loop-guard.ts +90 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @pify/swarm
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/@pify/swarm) [](https://www.npmjs.com/package/@pify/swarm)
|
|
3
|
+
[](https://github.com/pifydev/swarm/actions/workflows/ci.yml) [](https://www.npmjs.com/package/@pify/swarm) [](https://www.npmjs.com/package/@pify/swarm)
|
|
4
4
|
|
|
5
5
|
Run many [pi](https://github.com/earendil-works/pi) agents in parallel. One tool call fans a list of independent items out to child agents — with per-item routing, a concurrency queue, a live widget, and one aggregated report.
|
|
6
6
|
|
package/extensions/swarm.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
259
|
-
|
|
260
|
-
|
|
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
|
@@ -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
|
+
}
|