@pify/swarm 0.7.7 → 0.9.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 +20 -2
- package/extensions/swarm.ts +104 -32
- package/package.json +1 -1
- package/src/graph.ts +89 -0
- package/src/loop-guard.ts +90 -0
- package/src/schedule.ts +60 -0
- package/src/types.ts +4 -0
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ The catch is that "independent" is usually a small lie — the items do not depe
|
|
|
18
18
|
|
|
19
19
|
| Parameter | Type | Notes |
|
|
20
20
|
|---|---|---|
|
|
21
|
-
| `items` |
|
|
21
|
+
| `items` | array, 1–12 | A plain string per task, or `{task, id, needs}` to declare a dependency |
|
|
22
22
|
| `context` | string, optional | Prepended to every item, so shared constraints are written once |
|
|
23
23
|
| `agent` | string, optional | Force one agent type for all items instead of routing |
|
|
24
24
|
| `isolation` | `"worktree"`, optional | Give each item its own git worktree — use it when items write |
|
|
@@ -27,6 +27,24 @@ The catch is that "independent" is usually a small lie — the items do not depe
|
|
|
27
27
|
|
|
28
28
|
Blocking by default: returns `N done, M error` plus a per-item report.
|
|
29
29
|
|
|
30
|
+
### Dependencies: `needs`
|
|
31
|
+
|
|
32
|
+
An item can be a plain string (independent, as before) or an object that declares what it depends on:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"items": [
|
|
37
|
+
{ "id": "mig", "task": "write the DB migration for the users table" },
|
|
38
|
+
{ "id": "callers", "task": "update every caller of the old schema", "needs": ["mig"] },
|
|
39
|
+
{ "id": "tests", "task": "run the suite and fix what broke", "needs": ["callers"] }
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
An item starts only once its `needs` have finished, and each upstream item's output is prepended to it as a `## Output of <id>` block — so the thing the coordinator used to forget, passing X to the step that needs it, happens by construction. Independent items still run in parallel up to the concurrency cap; a chain runs in order; a diamond joins after both branches. Ids default to `t1`, `t2`, … when you omit them.
|
|
45
|
+
|
|
46
|
+
The whole graph is checked **before anything spawns**: a cycle, a self-edge, a duplicate id, or a reference to an unknown id is rejected outright, so a bad graph costs nothing. A flat list of strings has no edges and behaves exactly as it always did.
|
|
47
|
+
|
|
30
48
|
### `swarm_status`
|
|
31
49
|
|
|
32
50
|
| Parameter | Type | Notes |
|
|
@@ -77,7 +95,7 @@ The catalog is the same `.pi/agents/*.md` one [`@pify/subagent`](https://github.
|
|
|
77
95
|
|
|
78
96
|
## Where this sits in the suite
|
|
79
97
|
|
|
80
|
-
[`@pify/subagent`](https://github.com/pifydev/subagent) is one child and one task. `@pify/swarm` is many
|
|
98
|
+
[`@pify/subagent`](https://github.com/pifydev/subagent) is one child and one task. `@pify/swarm` is many items at once — independent, or wired together with a declarative `needs` graph (fan-out, chains, joins). [`@pify/workflow`](https://github.com/pifydev/workflow) is for when orchestration needs real control flow — loops, conditionals, retries, fan-out computed at run time — that a static graph can't express. Pick the smallest one that fits.
|
|
81
99
|
|
|
82
100
|
## License
|
|
83
101
|
|
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,
|
|
@@ -41,6 +42,8 @@ import { formatInbox, mailboxDir, mailboxPrompt, postMessage, readInbox } from "
|
|
|
41
42
|
import { parseAgentFile } from "../src/frontmatter.ts";
|
|
42
43
|
import { buildReport, buildStatusLine } from "../src/report.ts";
|
|
43
44
|
import { routeItem } from "../src/routing.ts";
|
|
45
|
+
import { normalizeItems } from "../src/graph.ts";
|
|
46
|
+
import { runGraph } from "../src/schedule.ts";
|
|
44
47
|
import { buildWidgetLines } from "../src/widget.ts";
|
|
45
48
|
import {
|
|
46
49
|
DEFAULT_CONCURRENCY,
|
|
@@ -183,6 +186,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
183
186
|
let session: AgentSession | null = null;
|
|
184
187
|
let unsubscribe: (() => void) | null = null;
|
|
185
188
|
let releaseLive: (() => void) | null = null;
|
|
189
|
+
let stallReason: string | null = null;
|
|
186
190
|
try {
|
|
187
191
|
let model = ctx.model ?? null;
|
|
188
192
|
if (def.model) {
|
|
@@ -230,11 +234,37 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
230
234
|
session = created.session;
|
|
231
235
|
releaseLive = live.register(runId, session);
|
|
232
236
|
|
|
237
|
+
const guard = new LoopGuard();
|
|
233
238
|
unsubscribe = session.subscribe((event) => {
|
|
234
|
-
|
|
239
|
+
const message = (
|
|
240
|
+
event as {
|
|
241
|
+
message?: {
|
|
242
|
+
role?: string;
|
|
243
|
+
usage?: { totalTokens?: number };
|
|
244
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
).message;
|
|
248
|
+
if (event.type === "message_end" && message?.role === "assistant") {
|
|
235
249
|
item.turns++;
|
|
236
|
-
const usage =
|
|
250
|
+
const usage = message.usage;
|
|
237
251
|
if (usage && typeof usage.totalTokens === "number") item.tokens += usage.totalTokens;
|
|
252
|
+
|
|
253
|
+
// Stop an item that is spinning — restating itself without acting —
|
|
254
|
+
// rather than letting it run to the turn cap. See loop-guard.ts.
|
|
255
|
+
if (!stallReason && Array.isArray(message.content)) {
|
|
256
|
+
const usedTool = message.content.some((c) => c.type === "toolCall");
|
|
257
|
+
const turnText = message.content
|
|
258
|
+
.filter((c) => c.type === "text" && typeof c.text === "string")
|
|
259
|
+
.map((c) => c.text)
|
|
260
|
+
.join("\n");
|
|
261
|
+
const verdict = guard.observe({ text: turnText, usedTool });
|
|
262
|
+
if (verdict.stalled) {
|
|
263
|
+
stallReason = verdict.reason ?? "no progress";
|
|
264
|
+
void session?.abort().catch(() => {});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
238
268
|
renderWidget();
|
|
239
269
|
if (item.turns >= def.maxTurns) void session?.abort().catch(() => {});
|
|
240
270
|
}
|
|
@@ -255,9 +285,18 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
255
285
|
.join("\n")
|
|
256
286
|
.trim();
|
|
257
287
|
|
|
258
|
-
item
|
|
259
|
-
|
|
260
|
-
|
|
288
|
+
// An item the loop guard stopped gave up rather than concluded — mark it
|
|
289
|
+
// so the aggregated report does not read it as a finished answer.
|
|
290
|
+
item.result = stallReason
|
|
291
|
+
? `${text ? `${text}\n\n` : ""}[stopped: no progress — the agent ${stallReason}]`
|
|
292
|
+
: text || null;
|
|
293
|
+
item.status = stallReason
|
|
294
|
+
? "aborted"
|
|
295
|
+
: last?.stopReason === "aborted"
|
|
296
|
+
? "aborted"
|
|
297
|
+
: last?.stopReason === "error"
|
|
298
|
+
? "error"
|
|
299
|
+
: "done";
|
|
261
300
|
if (item.status === "error") item.error = text || "child session error";
|
|
262
301
|
// A child that stopped cleanly and said nothing has not answered — the
|
|
263
302
|
// fix subagent already carries and this executor never received. Left
|
|
@@ -291,7 +330,11 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
291
330
|
}
|
|
292
331
|
}
|
|
293
332
|
|
|
294
|
-
/**
|
|
333
|
+
/**
|
|
334
|
+
* Readiness scheduler: run each item as soon as its `needs` are done, up to
|
|
335
|
+
* DEFAULT_CONCURRENCY at once. A flat run (no needs anywhere) makes every
|
|
336
|
+
* item ready immediately, so this is identical to the old parallel pool.
|
|
337
|
+
*/
|
|
295
338
|
async function executeRun(
|
|
296
339
|
ctx: UiContext,
|
|
297
340
|
run: SwarmRun,
|
|
@@ -302,25 +345,33 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
302
345
|
): Promise<void> {
|
|
303
346
|
// One shared log per run; only created when the caller asked for it.
|
|
304
347
|
const mailbox = useMailbox ? mailboxDir(getAgentDir(), run.runId) : undefined;
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
|
|
348
|
+
|
|
349
|
+
// A dependent structurally receives each upstream's output — the thing a
|
|
350
|
+
// hand-sequenced coordinator forgets. Prepended to the shared preamble.
|
|
351
|
+
const contextFor = (upstream: ItemState[]): string => {
|
|
352
|
+
if (upstream.length === 0) return context;
|
|
353
|
+
const blocks = upstream.map((up) => {
|
|
354
|
+
const body = up.result ?? (up.error ? `(failed: ${up.error})` : "(no output)");
|
|
355
|
+
return `## Output of ${up.id}\n${body}`;
|
|
356
|
+
});
|
|
357
|
+
return [context, ...blocks].filter((s) => s && s.trim()).join("\n\n");
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// Run each item once its needs finish, up to the concurrency cap; a flat
|
|
361
|
+
// run (no needs) has everything ready at once, exactly like the old pool.
|
|
362
|
+
await runGraph(
|
|
363
|
+
run.items,
|
|
364
|
+
DEFAULT_CONCURRENCY,
|
|
365
|
+
async (item, upstream) => {
|
|
313
366
|
const def = routeItem(item.item, defs, fixed);
|
|
314
367
|
item.agent = def.name;
|
|
368
|
+
const itemContext = contextFor(upstream);
|
|
315
369
|
if (isolate) {
|
|
316
370
|
try {
|
|
317
371
|
const iso = createIsolationWorktree(ctx.cwd, run.runId + "-i" + (item.index + 1));
|
|
318
|
-
await runItem(ctx, run.runId, def, item,
|
|
319
|
-
// Remove the worktree when the item changed nothing
|
|
320
|
-
//
|
|
321
|
-
// was imported and never called, leaking a worktree + branch per
|
|
322
|
-
// read-only item). Kept when there is work to merge, and only then
|
|
323
|
-
// is the merge note worth showing.
|
|
372
|
+
await runItem(ctx, run.runId, def, item, itemContext, iso.path, mailbox);
|
|
373
|
+
// Remove the worktree when the item changed nothing (the leak
|
|
374
|
+
// removeIfUnchanged fixes); keep it when there is work to merge.
|
|
324
375
|
const removed = removeIfUnchanged(ctx.cwd, iso);
|
|
325
376
|
if (item.result !== null) {
|
|
326
377
|
item.result = `${item.result}\n\n${removed ? CLEAN_WORKTREE_NOTE : isolationNote(iso)}`;
|
|
@@ -330,11 +381,12 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
330
381
|
item.error = err instanceof Error ? err.message : String(err);
|
|
331
382
|
}
|
|
332
383
|
} else {
|
|
333
|
-
await runItem(ctx, run.runId, def, item,
|
|
384
|
+
await runItem(ctx, run.runId, def, item, itemContext, undefined, mailbox);
|
|
334
385
|
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
|
|
386
|
+
},
|
|
387
|
+
() => run.status === "cancelled",
|
|
388
|
+
);
|
|
389
|
+
|
|
338
390
|
if (run.status !== "cancelled") run.status = "done";
|
|
339
391
|
run.finishedAt = Date.now();
|
|
340
392
|
pi.appendEntry(RUN_ENTRY, run);
|
|
@@ -374,9 +426,24 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
374
426
|
"Write each item as a self-contained brief — children see nothing else. For MUTATING items set " +
|
|
375
427
|
"isolation=worktree: each item gets its own git worktree and branch; reports say how to merge. " +
|
|
376
428
|
"mailbox=true adds swarm_post/swarm_inbox so agents can warn each other about shared files and " +
|
|
377
|
-
"conventions instead of silently conflicting."
|
|
429
|
+
"conventions instead of silently conflicting. " +
|
|
430
|
+
"An item can be a plain string (independent) OR an object {task, id, needs:[ids]} to declare a " +
|
|
431
|
+
"dependency: a needed item's output is prepended to the dependent automatically, and the dependent " +
|
|
432
|
+
"starts only once its needs finish. A cycle, a self-edge, or an unknown id is rejected before anything runs.",
|
|
378
433
|
parameters: Type.Object({
|
|
379
|
-
items: Type.Array(
|
|
434
|
+
items: Type.Array(
|
|
435
|
+
Type.Union([
|
|
436
|
+
Type.String({ description: "A self-contained task brief (independent item)" }),
|
|
437
|
+
Type.Object({
|
|
438
|
+
task: Type.String({ description: "A self-contained task brief" }),
|
|
439
|
+
id: Type.Optional(Type.String({ description: "Stable id other items can reference in needs (default t1, t2, …)" })),
|
|
440
|
+
needs: Type.Optional(
|
|
441
|
+
Type.Array(Type.String(), { description: "Ids of items that must finish first; their output is prepended to this item" }),
|
|
442
|
+
),
|
|
443
|
+
}),
|
|
444
|
+
]),
|
|
445
|
+
{ minItems: 1, maxItems: MAX_ITEMS },
|
|
446
|
+
),
|
|
380
447
|
context: Type.Optional(Type.String({ description: "Shared preamble for every item" })),
|
|
381
448
|
agent: Type.Optional(Type.String({ description: "Force one agent type for all items" })),
|
|
382
449
|
isolation: Type.Optional(Type.String({ description: "Set to worktree to give each item its own git worktree (for mutating items)" })),
|
|
@@ -388,7 +455,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
388
455
|
async execute(
|
|
389
456
|
_id,
|
|
390
457
|
params: {
|
|
391
|
-
items: string[]
|
|
458
|
+
items: Array<string | { task: string; id?: string; needs?: string[] }>;
|
|
392
459
|
context?: string;
|
|
393
460
|
agent?: string;
|
|
394
461
|
background?: boolean;
|
|
@@ -400,8 +467,11 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
400
467
|
ctx,
|
|
401
468
|
) {
|
|
402
469
|
const uiCtx = ctx as UiContext;
|
|
403
|
-
|
|
404
|
-
|
|
470
|
+
// Normalize strings/objects into graph nodes and reject a bad graph
|
|
471
|
+
// (cycle, self-edge, unknown or duplicate id) BEFORE spawning anything.
|
|
472
|
+
const { nodes, error } = normalizeItems(params.items ?? []);
|
|
473
|
+
if (error) throw new Error(`swarm_run: ${error}`);
|
|
474
|
+
if (nodes.length === 0) throw new Error("swarm_run requires at least one non-empty item.");
|
|
405
475
|
if (params.agent && !defs.has(params.agent.toLowerCase())) {
|
|
406
476
|
throw new Error(`Unknown agent type "${params.agent}". Available: ${[...defs.keys()].sort().join(", ")}`);
|
|
407
477
|
}
|
|
@@ -416,9 +486,11 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
416
486
|
status: "running",
|
|
417
487
|
startedAt: Date.now(),
|
|
418
488
|
finishedAt: null,
|
|
419
|
-
items:
|
|
489
|
+
items: nodes.map((node, index) => ({
|
|
420
490
|
index,
|
|
421
|
-
|
|
491
|
+
id: node.id,
|
|
492
|
+
item: node.task,
|
|
493
|
+
needs: node.needs,
|
|
422
494
|
agent: params.agent?.toLowerCase() ?? "?",
|
|
423
495
|
status: "queued",
|
|
424
496
|
turns: 0,
|
|
@@ -469,7 +541,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
469
541
|
});
|
|
470
542
|
return {
|
|
471
543
|
content: [
|
|
472
|
-
{ type: "text", text: `Swarm ${run.runId} started (${items.length} items). Poll swarm_status runId="${run.runId}".` },
|
|
544
|
+
{ type: "text", text: `Swarm ${run.runId} started (${run.items.length} items). Poll swarm_status runId="${run.runId}".` },
|
|
473
545
|
],
|
|
474
546
|
details: { runId: run.runId },
|
|
475
547
|
};
|
package/package.json
CHANGED
package/src/graph.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a list of items — some depending on others — into a runnable graph.
|
|
3
|
+
*
|
|
4
|
+
* swarm's flat mode fans N independent items out in parallel. But real work is
|
|
5
|
+
* rarely that flat: "write the migration, THEN update the callers, THEN run the
|
|
6
|
+
* tests" is a chain, and "gather these three, then summarize" is a join. Left
|
|
7
|
+
* to the coordinator to sequence by hand, the dependency is a thing it can
|
|
8
|
+
* forget — and the classic failure is forgetting to pass the upstream's output
|
|
9
|
+
* to the downstream item at all.
|
|
10
|
+
*
|
|
11
|
+
* So an item may declare `needs: [id, …]`. This module is the pure half: it
|
|
12
|
+
* normalizes items (string or object) into nodes with stable ids, and rejects a
|
|
13
|
+
* bad graph — a self-edge, a reference to an unknown id, a duplicate id, or a
|
|
14
|
+
* cycle — BEFORE anything is spawned, because a bad graph should cost nothing.
|
|
15
|
+
* The scheduler (in the extension) then runs each item as soon as its needs are
|
|
16
|
+
* done and prepends every upstream's output to it.
|
|
17
|
+
*
|
|
18
|
+
* A flat list (no `needs` anywhere) validates trivially and every node is ready
|
|
19
|
+
* at once — identical to the old parallel fan-out. Zero dependencies.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface RawItem {
|
|
23
|
+
task: string;
|
|
24
|
+
id?: string;
|
|
25
|
+
needs?: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GraphNode {
|
|
29
|
+
id: string;
|
|
30
|
+
task: string;
|
|
31
|
+
needs: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface NormalizeResult {
|
|
35
|
+
nodes: GraphNode[];
|
|
36
|
+
error: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Accept a plain string or an object; give every item a stable id and clean needs. */
|
|
40
|
+
export function normalizeItems(items: Array<string | RawItem>): NormalizeResult {
|
|
41
|
+
const nodes: GraphNode[] = [];
|
|
42
|
+
for (let i = 0; i < items.length; i++) {
|
|
43
|
+
const raw = items[i]!;
|
|
44
|
+
const isString = typeof raw === "string";
|
|
45
|
+
const task = (isString ? raw : raw.task ?? "").trim();
|
|
46
|
+
if (!task) return { nodes: [], error: `item ${i + 1} has no task text` };
|
|
47
|
+
const id = !isString && typeof raw.id === "string" && raw.id.trim() ? raw.id.trim() : `t${i + 1}`;
|
|
48
|
+
const needs =
|
|
49
|
+
!isString && Array.isArray(raw.needs)
|
|
50
|
+
? raw.needs.filter((n): n is string => typeof n === "string" && n.trim() !== "").map((n) => n.trim())
|
|
51
|
+
: [];
|
|
52
|
+
nodes.push({ id, task, needs });
|
|
53
|
+
}
|
|
54
|
+
const error = validateGraph(nodes);
|
|
55
|
+
return { nodes, error };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Reject a self-edge, an unknown reference, a duplicate id, or a cycle. */
|
|
59
|
+
export function validateGraph(nodes: GraphNode[]): string | null {
|
|
60
|
+
const ids = new Set<string>();
|
|
61
|
+
for (const n of nodes) {
|
|
62
|
+
if (ids.has(n.id)) return `duplicate item id "${n.id}"`;
|
|
63
|
+
ids.add(n.id);
|
|
64
|
+
}
|
|
65
|
+
for (const n of nodes) {
|
|
66
|
+
for (const dep of n.needs) {
|
|
67
|
+
if (dep === n.id) return `item "${n.id}" cannot depend on itself`;
|
|
68
|
+
if (!ids.has(dep)) return `item "${n.id}" needs unknown item "${dep}"`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
72
|
+
const state = new Map<string, 0 | 1 | 2>(); // 0 unseen, 1 on stack, 2 done
|
|
73
|
+
const hasCycle = (id: string): boolean => {
|
|
74
|
+
const s = state.get(id) ?? 0;
|
|
75
|
+
if (s === 1) return true;
|
|
76
|
+
if (s === 2) return false;
|
|
77
|
+
state.set(id, 1);
|
|
78
|
+
for (const dep of byId.get(id)!.needs) if (hasCycle(dep)) return true;
|
|
79
|
+
state.set(id, 2);
|
|
80
|
+
return false;
|
|
81
|
+
};
|
|
82
|
+
for (const n of nodes) if (hasCycle(n.id)) return `dependency cycle involving "${n.id}"`;
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Does any node declare a dependency? If not, the run is a plain flat fan-out. */
|
|
87
|
+
export function hasEdges(nodes: GraphNode[]): boolean {
|
|
88
|
+
return nodes.some((n) => n.needs.length > 0);
|
|
89
|
+
}
|
|
@@ -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
|
+
}
|
package/src/schedule.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The readiness scheduler, pure and testable.
|
|
3
|
+
*
|
|
4
|
+
* Given nodes that may depend on one another, run each one as soon as its
|
|
5
|
+
* `needs` have finished, never more than `concurrency` at a time. The caller's
|
|
6
|
+
* `run(node, upstream)` does the actual work and is handed the finished
|
|
7
|
+
* upstream nodes (in `needs` order) so it can thread their output into the
|
|
8
|
+
* dependent. A node whose run rejects still counts as done, so a failure
|
|
9
|
+
* unblocks its dependents rather than wedging the whole graph. `cancelled()` is
|
|
10
|
+
* polled between launches to stop taking on new work.
|
|
11
|
+
*
|
|
12
|
+
* A flat set (no `needs`) makes every node ready at once, so this collapses to
|
|
13
|
+
* a plain concurrency-capped fan-out. Zero dependencies; the graph itself must
|
|
14
|
+
* already be validated (see graph.ts) — this assumes no cycle.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface Schedulable {
|
|
18
|
+
id: string;
|
|
19
|
+
needs: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function runGraph<T extends Schedulable>(
|
|
23
|
+
nodes: readonly T[],
|
|
24
|
+
concurrency: number,
|
|
25
|
+
run: (node: T, upstream: T[]) => void | Promise<void>,
|
|
26
|
+
cancelled: () => boolean = () => false,
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
29
|
+
const done = new Set<string>();
|
|
30
|
+
const started = new Set<string>();
|
|
31
|
+
const inFlight = new Map<string, Promise<void>>();
|
|
32
|
+
const cap = Math.max(1, concurrency);
|
|
33
|
+
|
|
34
|
+
const isReady = (n: T) => !started.has(n.id) && n.needs.every((d) => done.has(d));
|
|
35
|
+
|
|
36
|
+
while (!cancelled()) {
|
|
37
|
+
for (const n of nodes) {
|
|
38
|
+
if (inFlight.size >= cap) break;
|
|
39
|
+
if (!isReady(n)) continue;
|
|
40
|
+
started.add(n.id);
|
|
41
|
+
const upstream = n.needs.map((id) => byId.get(id)).filter((x): x is T => x !== undefined);
|
|
42
|
+
const p = Promise.resolve()
|
|
43
|
+
.then(() => run(n, upstream))
|
|
44
|
+
.catch(() => {
|
|
45
|
+
// The caller records its own failure; here a rejected node must still
|
|
46
|
+
// settle so dependents unblock instead of the graph hanging.
|
|
47
|
+
})
|
|
48
|
+
.then(() => {
|
|
49
|
+
done.add(n.id);
|
|
50
|
+
inFlight.delete(n.id);
|
|
51
|
+
});
|
|
52
|
+
inFlight.set(n.id, p);
|
|
53
|
+
}
|
|
54
|
+
if (inFlight.size === 0) break; // nothing running and nothing newly ready
|
|
55
|
+
await Promise.race(inFlight.values());
|
|
56
|
+
}
|
|
57
|
+
// A cancel can leave work in flight; let it settle so the caller's record is
|
|
58
|
+
// complete rather than half-written.
|
|
59
|
+
await Promise.all(inFlight.values());
|
|
60
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -52,7 +52,11 @@ export type ItemStatus = "queued" | "running" | "done" | "error" | "aborted";
|
|
|
52
52
|
|
|
53
53
|
export interface ItemState {
|
|
54
54
|
index: number;
|
|
55
|
+
/** Stable id used for dependency references, e.g. "t1" or a caller-given id. */
|
|
56
|
+
id: string;
|
|
55
57
|
item: string;
|
|
58
|
+
/** Ids of items that must finish before this one starts (empty = flat). */
|
|
59
|
+
needs: string[];
|
|
56
60
|
agent: string;
|
|
57
61
|
status: ItemStatus;
|
|
58
62
|
turns: number;
|