@pify/swarm 0.5.0 → 0.6.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 +56 -17
- package/extensions/swarm.ts +27 -1
- package/package.json +1 -1
- package/src/pending.ts +86 -0
package/README.md
CHANGED
|
@@ -1,16 +1,50 @@
|
|
|
1
1
|
# @pify/swarm
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
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.
|
|
4
4
|
|
|
5
5
|
Part of the [Pify suite](https://github.com/pifydev). Install with [`pify install swarm`](https://github.com/pifydev/cli) or `pi install npm:@pify/swarm`.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Why
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
Some work is a list of things that do not depend on each other: audit twelve modules, summarise nine files, check every package for the same problem. Doing that in one conversation is slow and fills the context with material the main thread does not need. Doing it with twelve separate delegation calls is the same work typed twelve times.
|
|
10
|
+
|
|
11
|
+
The catch is that "independent" is usually a small lie — the items do not depend on each other's *results*, but they may touch the same files. That is what the mailbox and worktree isolation below are for.
|
|
12
|
+
|
|
13
|
+
## Tools
|
|
14
|
+
|
|
15
|
+
### `swarm_run`
|
|
16
|
+
|
|
17
|
+
| Parameter | Type | Notes |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| `items` | string[], 1–12 | One task per item; four run at a time, the rest queue |
|
|
20
|
+
| `context` | string, optional | Prepended to every item, so shared constraints are written once |
|
|
21
|
+
| `agent` | string, optional | Force one agent type for all items instead of routing |
|
|
22
|
+
| `isolation` | `"worktree"`, optional | Give each item its own git worktree — use it when items write |
|
|
23
|
+
| `mailbox` | boolean, optional | Give the children `swarm_post` / `swarm_inbox` |
|
|
24
|
+
| `background` | boolean, optional | Return a `runId` immediately instead of blocking |
|
|
25
|
+
|
|
26
|
+
Blocking by default: returns `N done, M error` plus a per-item report.
|
|
27
|
+
|
|
28
|
+
### `swarm_status`
|
|
29
|
+
|
|
30
|
+
| Parameter | Type | Notes |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| `runId` | string, optional | Defaults to the most recent run |
|
|
33
|
+
|
|
34
|
+
Live per-item progress (`1:scout=running(3t) · 2:reviewer=queued`), and the full report once the run finishes. Completed runs survive `/reload`.
|
|
35
|
+
|
|
36
|
+
### `swarm_post` / `swarm_inbox`
|
|
37
|
+
|
|
38
|
+
Registered for the children only, and only when `mailbox: true`.
|
|
39
|
+
|
|
40
|
+
- `swarm_post(message)` — tell the siblings something that changes their work: a shared file you modified, a convention you had to pick, a blocker they will hit too.
|
|
41
|
+
- `swarm_inbox()` — read what the others have posted since your last check.
|
|
42
|
+
|
|
43
|
+
Without it, parallel agents cannot see each other, so two of them cheerfully fix the same shared helper in two different ways. It is deliberately not a chat: no addressing, no waiting, no replies. An append-only log per run, and an agent never sees its own posts echoed back. A torn line from two simultaneous appends is skipped rather than failing the read.
|
|
44
|
+
|
|
45
|
+
## Per-item routing
|
|
46
|
+
|
|
47
|
+
Agent definitions declare what they are for, and each item picks its own:
|
|
14
48
|
|
|
15
49
|
```markdown
|
|
16
50
|
---
|
|
@@ -21,23 +55,28 @@ match_keywords: rust, memory safety
|
|
|
21
55
|
---
|
|
22
56
|
```
|
|
23
57
|
|
|
24
|
-
-
|
|
58
|
+
`match_patterns` are globs matched against path-like tokens in the item — the longest match wins, so a specific rule beats a general one. `match_keywords` match the item's words. `review src/auth.rs` routes to the Rust auditor; `test the login flow` to a tester; anything matching nothing falls back to the read-only `scout`, so **the fallback can never mutate**.
|
|
25
59
|
|
|
26
|
-
|
|
60
|
+
The catalog is the same `.pi/agents/*.md` one [`@pify/subagent`](https://github.com/pifydev/subagent) reads — `description`, `tools`, `model`, `thinking`, `max_turns` — plus the two routing keys. Project-local definitions load only once pi's project trust has been granted.
|
|
27
61
|
|
|
28
|
-
|
|
62
|
+
## Behaviour
|
|
29
63
|
|
|
30
|
-
|
|
64
|
+
- **Independence by design.** Items share nothing, children cannot spawn children, and each child is capped at its agent's `max_turns`.
|
|
65
|
+
- **Stopping stops the children.** Pressing Esc, or switching away from the session, aborts every live child rather than leaving them talking to the provider on your money. A cancelled run keeps that verdict — it is never reported as done — and `swarm_status` shows what the items that did finish produced.
|
|
66
|
+
- **Isolated runs clean up after themselves.** With `isolation: "worktree"`, a worktree whose child changed nothing is removed along with its branch; otherwise a read-only step left one of each behind on every run. Anything uncommitted, and any commit the child made, is kept and reported.
|
|
31
67
|
|
|
32
|
-
|
|
68
|
+
## A background run comes back to you
|
|
33
69
|
|
|
34
|
-
|
|
35
|
-
- `swarm_inbox()` — read what the others posted since your last check.
|
|
70
|
+
`swarm_status` on a run still in flight used to say "still running", which left the model one option: ask again. Now the aggregated report is **delivered** into the conversation when the run finishes, and asking early returns a structured result carrying `retryable`, the elapsed time and `pollRequired: false` — a normal answer rather than an error, because a tool error over a condition only time resolves invites the model's retry machinery into a loop.
|
|
36
71
|
|
|
37
|
-
|
|
72
|
+
## Command
|
|
73
|
+
|
|
74
|
+
`/swarm` — runs in this session, and the agent types available for routing.
|
|
75
|
+
|
|
76
|
+
## Where this sits in the suite
|
|
77
|
+
|
|
78
|
+
[`@pify/subagent`](https://github.com/pifydev/subagent) is one child and one task. `@pify/swarm` is many independent items at once. [`@pify/workflow`](https://github.com/pifydev/workflow) is deterministic scripted orchestration for when the steps genuinely depend on each other. Pick the smallest one that fits.
|
|
38
79
|
|
|
39
80
|
## License
|
|
40
81
|
|
|
41
82
|
MIT © [Pify maintainers](https://github.com/pifydev)
|
|
42
|
-
|
|
43
|
-
**Isolated runs clean up after themselves** (v0.4): a worktree whose child changed nothing is removed along with its branch — otherwise a read-only step left one of each behind, per run. Anything uncommitted, or any commit the child made, is kept and reported.
|
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 { LiveChildren, cancelNote, type CancelReason } from "../src/cancel.ts";
|
|
29
|
+
import { DELIVERY_TYPE, deliveryMessage, pendingResult } from "../src/pending.ts";
|
|
29
30
|
import { createIsolationWorktree, isolationNote, removeIfUnchanged } from "../src/isolate.ts";
|
|
30
31
|
import { formatInbox, mailboxDir, mailboxPrompt, postMessage, readInbox } from "../src/mailbox.ts";
|
|
31
32
|
import { parseAgentFile } from "../src/frontmatter.ts";
|
|
@@ -402,7 +403,22 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
402
403
|
|
|
403
404
|
if (run.background) {
|
|
404
405
|
void executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true).then(() => {
|
|
405
|
-
notify(uiCtx, `swarm ${run.runId} finished
|
|
406
|
+
notify(uiCtx, `swarm ${run.runId} finished`, "info");
|
|
407
|
+
// The report goes to the agent, not only to the screen — otherwise
|
|
408
|
+
// asking again was its only way to find out.
|
|
409
|
+
try {
|
|
410
|
+
pi.sendMessage(
|
|
411
|
+
{
|
|
412
|
+
customType: DELIVERY_TYPE,
|
|
413
|
+
content: deliveryMessage(run.runId, "swarm", buildReport(run)),
|
|
414
|
+
display: true,
|
|
415
|
+
details: { runId: run.runId, status: run.status, items: run.items.length },
|
|
416
|
+
},
|
|
417
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
418
|
+
);
|
|
419
|
+
} catch {
|
|
420
|
+
// Delivery is a convenience; swarm_status still works.
|
|
421
|
+
}
|
|
406
422
|
});
|
|
407
423
|
return {
|
|
408
424
|
content: [
|
|
@@ -434,6 +450,16 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
434
450
|
async execute(_id, params: { runId?: string }) {
|
|
435
451
|
const run = params.runId ? runs.get(params.runId.trim()) : activeRun ?? [...runs.values()].pop();
|
|
436
452
|
if (!run) throw new Error("No swarm runs this session.");
|
|
453
|
+
if (run.status === "running") {
|
|
454
|
+
const pending = pendingResult({
|
|
455
|
+
id: run.runId,
|
|
456
|
+
kind: "running",
|
|
457
|
+
startedAt: run.startedAt,
|
|
458
|
+
now: Date.now(),
|
|
459
|
+
collectWith: "swarm_status",
|
|
460
|
+
});
|
|
461
|
+
return { content: [{ type: "text", text: pending.text }], details: pending.details as never };
|
|
462
|
+
}
|
|
437
463
|
const text =
|
|
438
464
|
run.status === "cancelled"
|
|
439
465
|
? "This run was cancelled before it finished. Below is what the items that did complete produced.\n" +
|
package/package.json
CHANGED
package/src/pending.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telling an agent to wait, without teaching it to poll.
|
|
3
|
+
*
|
|
4
|
+
* A background run gave the model exactly one way to find out it had
|
|
5
|
+
* finished: call the status tool again. "Still running — call agent_result
|
|
6
|
+
* later" is an instruction to spin, and models follow it, burning a turn and a
|
|
7
|
+
* request per check while the thing they are waiting for has not moved.
|
|
8
|
+
*
|
|
9
|
+
* Two halves fix that, and only together:
|
|
10
|
+
*
|
|
11
|
+
* - a not-ready answer that is a normal structured result rather than an
|
|
12
|
+
* error, carrying `retryable` and saying what to do *instead* of waiting.
|
|
13
|
+
* Throwing would be worse than useless — a tool error invites the model's
|
|
14
|
+
* own retry machinery into a loop over a condition that time, not
|
|
15
|
+
* retrying, resolves;
|
|
16
|
+
* - a push when the run actually finishes, so waiting is never the only
|
|
17
|
+
* option on the table.
|
|
18
|
+
*
|
|
19
|
+
* Pure: the shapes and the words. The extension owns the clock and the host.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type PendingKind = "queued" | "running";
|
|
23
|
+
|
|
24
|
+
export interface PendingInput {
|
|
25
|
+
/** The id the caller would poll with. */
|
|
26
|
+
id: string;
|
|
27
|
+
kind: PendingKind;
|
|
28
|
+
startedAt: number;
|
|
29
|
+
now: number;
|
|
30
|
+
/** What the caller asks for to collect it, e.g. `agent_result`. */
|
|
31
|
+
collectWith: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PendingResult {
|
|
35
|
+
text: string;
|
|
36
|
+
details: {
|
|
37
|
+
id: string;
|
|
38
|
+
status: PendingKind;
|
|
39
|
+
/** True: this will resolve on its own. It is a wait, not a failure. */
|
|
40
|
+
retryable: boolean;
|
|
41
|
+
elapsedMs: number;
|
|
42
|
+
/** False, and load-bearing: there is nothing to poll for. */
|
|
43
|
+
pollRequired: false;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function elapsed(ms: number): string {
|
|
48
|
+
if (ms < 1000) return "just started";
|
|
49
|
+
const seconds = Math.round(ms / 1000);
|
|
50
|
+
if (seconds < 60) return `${seconds}s so far`;
|
|
51
|
+
return `${Math.floor(seconds / 60)}m ${seconds % 60}s so far`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The answer to "is it done yet". It says no, says why that is fine, and
|
|
56
|
+
* closes the loop the question came from.
|
|
57
|
+
*/
|
|
58
|
+
export function pendingResult(input: PendingInput): PendingResult {
|
|
59
|
+
const ms = Math.max(0, input.now - input.startedAt);
|
|
60
|
+
const state = input.kind === "queued" ? "queued behind the concurrency cap" : "still running";
|
|
61
|
+
return {
|
|
62
|
+
text: [
|
|
63
|
+
`${input.id} is ${state} (${elapsed(ms)}).`,
|
|
64
|
+
"",
|
|
65
|
+
"Do not poll for it. The result is delivered to you automatically the moment it lands,",
|
|
66
|
+
`so there is nothing to wait for here — carry on with other work, or finish your turn and`,
|
|
67
|
+
`you will be picked back up. ${input.collectWith} is only needed if you want it early.`,
|
|
68
|
+
].join("\n"),
|
|
69
|
+
details: { id: input.id, status: input.kind, retryable: true, elapsedMs: ms, pollRequired: false },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** How a finished run introduces itself when it arrives unasked. */
|
|
74
|
+
export function deliveryMessage(id: string, label: string, body: string): string {
|
|
75
|
+
return [
|
|
76
|
+
`<${label}_result id="${id}">`,
|
|
77
|
+
body.trim(),
|
|
78
|
+
`</${label}_result>`,
|
|
79
|
+
"",
|
|
80
|
+
`This is ${id}, which you started in the background; it has just finished and this is its report.`,
|
|
81
|
+
"Fold it into what you are doing. If you had already moved on, say what it changes — or that it changes nothing.",
|
|
82
|
+
].join("\n");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The custom-message type a delivered result travels under. */
|
|
86
|
+
export const DELIVERY_TYPE = "pify-background-result";
|