@pify/swarm 0.2.1 → 0.3.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 +9 -0
- package/extensions/swarm.ts +85 -8
- package/package.json +1 -1
- package/src/mailbox.ts +112 -0
package/README.md
CHANGED
|
@@ -27,6 +27,15 @@ match_keywords: rust, memory safety
|
|
|
27
27
|
|
|
28
28
|
`@pify/subagent` = one child, one task. `@pify/swarm` = many independent items at once. `@pify/workflow` = deterministic scripted orchestration. Pick the smallest one that fits.
|
|
29
29
|
|
|
30
|
+
## Mailbox (v0.3)
|
|
31
|
+
|
|
32
|
+
`swarm_run(items, { mailbox: true })` gives every agent two extra tools:
|
|
33
|
+
|
|
34
|
+
- `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.
|
|
35
|
+
- `swarm_inbox()` — read what the others posted since your last check.
|
|
36
|
+
|
|
37
|
+
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.
|
|
38
|
+
|
|
30
39
|
## License
|
|
31
40
|
|
|
32
41
|
MIT © [Pify maintainers](https://github.com/pifydev)
|
package/extensions/swarm.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
getAgentDir,
|
|
18
18
|
SessionManager,
|
|
19
19
|
type AgentSession,
|
|
20
|
+
type ToolDefinition,
|
|
20
21
|
type ExtensionAPI,
|
|
21
22
|
type ExtensionContext,
|
|
22
23
|
} from "@earendil-works/pi-coding-agent";
|
|
@@ -25,6 +26,7 @@ import { Type } from "typebox";
|
|
|
25
26
|
|
|
26
27
|
import { BUILTIN_AGENTS } from "../src/builtin.ts";
|
|
27
28
|
import { createIsolationWorktree, isolationNote } from "../src/isolate.ts";
|
|
29
|
+
import { formatInbox, mailboxDir, mailboxPrompt, postMessage, readInbox } from "../src/mailbox.ts";
|
|
28
30
|
import { parseAgentFile } from "../src/frontmatter.ts";
|
|
29
31
|
import { buildReport, buildStatusLine } from "../src/report.ts";
|
|
30
32
|
import { routeItem } from "../src/routing.ts";
|
|
@@ -100,7 +102,59 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
100
102
|
|
|
101
103
|
// ── Child runner (subagent-proven pattern, one per item) ─────────────
|
|
102
104
|
|
|
103
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Mailbox tools for one child. Each agent posts under its own label and
|
|
107
|
+
* never sees its own posts echoed back; `seen` advances per agent so a
|
|
108
|
+
* second swarm_inbox only reports what arrived since the first.
|
|
109
|
+
*/
|
|
110
|
+
function mailboxTools(dir: string, label: string): ToolDefinition[] {
|
|
111
|
+
let seen = 0;
|
|
112
|
+
return [
|
|
113
|
+
{
|
|
114
|
+
name: "swarm_post",
|
|
115
|
+
label: "Post to swarm",
|
|
116
|
+
description:
|
|
117
|
+
"Tell the other agents in this swarm something that changes their work: a shared file you " +
|
|
118
|
+
"modified, a convention you had to choose, a blocker they will hit too. Not for progress " +
|
|
119
|
+
"narration — only facts a sibling needs to avoid redoing or undoing your work.",
|
|
120
|
+
parameters: Type.Object({
|
|
121
|
+
message: Type.String({ description: "One fact the other agents need" }),
|
|
122
|
+
}),
|
|
123
|
+
async execute(_id: string, params: { message: string }) {
|
|
124
|
+
const posted = postMessage(dir, label, params.message, Date.now());
|
|
125
|
+
return {
|
|
126
|
+
content: [{ type: "text", text: `Posted #${posted.seq} to the swarm.` }],
|
|
127
|
+
details: { seq: posted.seq },
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: "swarm_inbox",
|
|
133
|
+
label: "Read swarm inbox",
|
|
134
|
+
description:
|
|
135
|
+
"Read what the other agents in this swarm have posted since you last checked. Call it " +
|
|
136
|
+
"before you start working and again before you finish.",
|
|
137
|
+
parameters: Type.Object({}),
|
|
138
|
+
async execute() {
|
|
139
|
+
const read = readInbox(dir, label, seen);
|
|
140
|
+
seen = read.nextSeq;
|
|
141
|
+
return {
|
|
142
|
+
content: [{ type: "text", text: formatInbox(read) }],
|
|
143
|
+
details: { count: read.messages.length },
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
] as unknown as ToolDefinition[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function runItem(
|
|
151
|
+
ctx: UiContext,
|
|
152
|
+
def: AgentDef,
|
|
153
|
+
item: ItemState,
|
|
154
|
+
context: string,
|
|
155
|
+
workDir?: string,
|
|
156
|
+
mailbox?: string,
|
|
157
|
+
): Promise<void> {
|
|
104
158
|
item.status = "running";
|
|
105
159
|
renderWidget();
|
|
106
160
|
let session: AgentSession | null = null;
|
|
@@ -125,6 +179,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
125
179
|
model,
|
|
126
180
|
thinkingLevel: (def.thinking ?? pi.getThinkingLevel()) as never,
|
|
127
181
|
tools: def.tools,
|
|
182
|
+
...(mailbox ? { customTools: mailboxTools(mailbox, item.agent + "-" + item.index) } : {}),
|
|
128
183
|
resourceLoader: new DefaultResourceLoader({
|
|
129
184
|
cwd: workDir ?? ctx.cwd,
|
|
130
185
|
agentDir: getAgentDir(),
|
|
@@ -136,6 +191,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
136
191
|
...(promptOptions.appendSystemPrompt ? [promptOptions.appendSystemPrompt] : []),
|
|
137
192
|
def.systemPrompt,
|
|
138
193
|
"You are one agent in a swarm, handling exactly one item. Your final assistant message is the deliverable — make it complete and self-contained.",
|
|
194
|
+
...(mailbox ? [mailboxPrompt(item.agent + "-" + item.index)] : []),
|
|
139
195
|
],
|
|
140
196
|
}),
|
|
141
197
|
});
|
|
@@ -193,7 +249,16 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
193
249
|
}
|
|
194
250
|
|
|
195
251
|
/** Pool executor: at most DEFAULT_CONCURRENCY items in flight. */
|
|
196
|
-
async function executeRun(
|
|
252
|
+
async function executeRun(
|
|
253
|
+
ctx: UiContext,
|
|
254
|
+
run: SwarmRun,
|
|
255
|
+
context: string,
|
|
256
|
+
fixed?: string,
|
|
257
|
+
isolate?: boolean,
|
|
258
|
+
useMailbox?: boolean,
|
|
259
|
+
): Promise<void> {
|
|
260
|
+
// One shared log per run; only created when the caller asked for it.
|
|
261
|
+
const mailbox = useMailbox ? mailboxDir(getAgentDir(), run.runId) : undefined;
|
|
197
262
|
const queue = [...run.items];
|
|
198
263
|
const workers = Array.from({ length: Math.min(DEFAULT_CONCURRENCY, queue.length) }, async () => {
|
|
199
264
|
for (;;) {
|
|
@@ -204,14 +269,14 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
204
269
|
if (isolate) {
|
|
205
270
|
try {
|
|
206
271
|
const iso = createIsolationWorktree(ctx.cwd, run.runId + "-i" + (item.index + 1));
|
|
207
|
-
await runItem(ctx, def, item, context, iso.path);
|
|
272
|
+
await runItem(ctx, def, item, context, iso.path, mailbox);
|
|
208
273
|
if (item.result !== null) item.result = `${item.result}\n\n${isolationNote(iso)}`;
|
|
209
274
|
} catch (err) {
|
|
210
275
|
item.status = "error";
|
|
211
276
|
item.error = err instanceof Error ? err.message : String(err);
|
|
212
277
|
}
|
|
213
278
|
} else {
|
|
214
|
-
await runItem(ctx, def, item, context);
|
|
279
|
+
await runItem(ctx, def, item, context, undefined, mailbox);
|
|
215
280
|
}
|
|
216
281
|
}
|
|
217
282
|
});
|
|
@@ -233,17 +298,29 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
233
298
|
"read-only scout; set agent to force one type for all items. context is prepended to every item. " +
|
|
234
299
|
"Blocking by default (returns the aggregated report); background=true returns a runId for swarm_status. " +
|
|
235
300
|
"Write each item as a self-contained brief — children see nothing else. For MUTATING items set " +
|
|
236
|
-
"isolation=worktree: each item gets its own git worktree and branch; reports say how to merge."
|
|
301
|
+
"isolation=worktree: each item gets its own git worktree and branch; reports say how to merge. " +
|
|
302
|
+
"mailbox=true adds swarm_post/swarm_inbox so agents can warn each other about shared files and " +
|
|
303
|
+
"conventions instead of silently conflicting.",
|
|
237
304
|
parameters: Type.Object({
|
|
238
305
|
items: Type.Array(Type.String(), { minItems: 1, maxItems: MAX_ITEMS }),
|
|
239
306
|
context: Type.Optional(Type.String({ description: "Shared preamble for every item" })),
|
|
240
307
|
agent: Type.Optional(Type.String({ description: "Force one agent type for all items" })),
|
|
241
308
|
isolation: Type.Optional(Type.String({ description: "Set to worktree to give each item its own git worktree (for mutating items)" })),
|
|
309
|
+
mailbox: Type.Optional(
|
|
310
|
+
Type.Boolean({ description: "Give the agents swarm_post/swarm_inbox to share facts mid-run" }),
|
|
311
|
+
),
|
|
242
312
|
background: Type.Optional(Type.Boolean()),
|
|
243
313
|
}),
|
|
244
314
|
async execute(
|
|
245
315
|
_id,
|
|
246
|
-
params: {
|
|
316
|
+
params: {
|
|
317
|
+
items: string[];
|
|
318
|
+
context?: string;
|
|
319
|
+
agent?: string;
|
|
320
|
+
background?: boolean;
|
|
321
|
+
isolation?: string;
|
|
322
|
+
mailbox?: boolean;
|
|
323
|
+
},
|
|
247
324
|
_signal,
|
|
248
325
|
_onUpdate,
|
|
249
326
|
ctx,
|
|
@@ -281,7 +358,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
281
358
|
renderWidget(uiCtx);
|
|
282
359
|
|
|
283
360
|
if (run.background) {
|
|
284
|
-
void executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree").then(() => {
|
|
361
|
+
void executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true).then(() => {
|
|
285
362
|
notify(uiCtx, `swarm ${run.runId} finished — collect with swarm_status`, "info");
|
|
286
363
|
});
|
|
287
364
|
return {
|
|
@@ -292,7 +369,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
292
369
|
};
|
|
293
370
|
}
|
|
294
371
|
|
|
295
|
-
await executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree");
|
|
372
|
+
await executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true);
|
|
296
373
|
return {
|
|
297
374
|
content: [{ type: "text", text: buildReport(run) }],
|
|
298
375
|
details: { runId: run.runId },
|
package/package.json
CHANGED
package/src/mailbox.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run mailbox (v0.3, gjczone's shared inbox/outbox). Swarm agents work the
|
|
3
|
+
* same repository at the same time and cannot see each other, so two of them
|
|
4
|
+
* happily fix the same shared helper in two different ways. A mailbox is the
|
|
5
|
+
* cheapest fix: one append-only log per run, readable by every sibling.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately not a chat. Agents post facts they discovered that change
|
|
8
|
+
* someone else's work, and read what others posted; there is no addressing,
|
|
9
|
+
* no waiting, and no reply. Anything richer needs steering, which is a
|
|
10
|
+
* different feature.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
export interface MailMessage {
|
|
17
|
+
seq: number;
|
|
18
|
+
from: string;
|
|
19
|
+
text: string;
|
|
20
|
+
timestamp: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const MAX_MESSAGE_CHARS = 1200;
|
|
24
|
+
export const MAX_INBOX_MESSAGES = 30;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One directory per run under the agent dir. Run ids are generated locally,
|
|
28
|
+
* but this builds a filesystem path, so it stays a single flat segment: no
|
|
29
|
+
* separators and no `..` can survive the sanitizer.
|
|
30
|
+
*/
|
|
31
|
+
export function mailboxDir(agentDir: string, runId: string): string {
|
|
32
|
+
const safe =
|
|
33
|
+
runId
|
|
34
|
+
.replace(/[^A-Za-z0-9._-]/g, "-")
|
|
35
|
+
.replace(/\.{2,}/g, "-")
|
|
36
|
+
.replace(/^[.-]+/, "")
|
|
37
|
+
.slice(0, 80) || "run";
|
|
38
|
+
return join(agentDir, "swarm-mailbox", safe);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function logPath(dir: string): string {
|
|
42
|
+
return join(dir, "messages.jsonl");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function readMailbox(dir: string): MailMessage[] {
|
|
46
|
+
let raw: string;
|
|
47
|
+
try {
|
|
48
|
+
raw = readFileSync(logPath(dir), "utf8");
|
|
49
|
+
} catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
const messages: MailMessage[] = [];
|
|
53
|
+
for (const line of raw.split("\n")) {
|
|
54
|
+
if (!line.trim()) continue;
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(line) as MailMessage;
|
|
57
|
+
if (typeof parsed.seq === "number" && typeof parsed.from === "string" && typeof parsed.text === "string") {
|
|
58
|
+
messages.push(parsed);
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// a torn line from a concurrent append — skip it, never fail the read
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return messages;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Append one message. Concurrency is handled by the filesystem: a single
|
|
69
|
+
* append of one line is atomic enough for this, and a torn read is skipped
|
|
70
|
+
* rather than treated as an error.
|
|
71
|
+
*/
|
|
72
|
+
export function postMessage(dir: string, from: string, text: string, now: number): MailMessage {
|
|
73
|
+
const trimmed = text.trim().slice(0, MAX_MESSAGE_CHARS);
|
|
74
|
+
if (!trimmed) throw new Error("A mailbox message cannot be empty.");
|
|
75
|
+
mkdirSync(dir, { recursive: true });
|
|
76
|
+
const seq = readMailbox(dir).length + 1;
|
|
77
|
+
const message: MailMessage = { seq, from, text: trimmed, timestamp: now };
|
|
78
|
+
appendFileSync(logPath(dir), `${JSON.stringify(message)}\n`);
|
|
79
|
+
return message;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface InboxRead {
|
|
83
|
+
messages: MailMessage[];
|
|
84
|
+
/** Pass back as `sinceSeq` to read only what arrives after this. */
|
|
85
|
+
nextSeq: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Messages from OTHER agents after `sinceSeq`. Own posts are never echoed. */
|
|
89
|
+
export function readInbox(dir: string, reader: string, sinceSeq = 0): InboxRead {
|
|
90
|
+
const all = readMailbox(dir);
|
|
91
|
+
const highest = all.reduce((max, m) => Math.max(max, m.seq), 0);
|
|
92
|
+
const messages = all
|
|
93
|
+
.filter((m) => m.seq > sinceSeq && m.from !== reader)
|
|
94
|
+
.slice(-MAX_INBOX_MESSAGES);
|
|
95
|
+
return { messages, nextSeq: highest };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function formatInbox(read: InboxRead): string {
|
|
99
|
+
if (read.messages.length === 0) return "No new messages from the other agents.";
|
|
100
|
+
return read.messages.map((m) => `[#${m.seq} from ${m.from}] ${m.text}`).join("\n");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The instruction children get, naming their own label so posts are attributable. */
|
|
104
|
+
export function mailboxPrompt(label: string): string {
|
|
105
|
+
return [
|
|
106
|
+
`You are agent "${label}" in a parallel swarm working the same repository.`,
|
|
107
|
+
"Use swarm_post to tell the other agents something that changes their work:",
|
|
108
|
+
"a shared file you modified, a convention you had to pick, a blocker they will hit too.",
|
|
109
|
+
"Use swarm_inbox before you start and again before you finish, so you do not redo",
|
|
110
|
+
"or undo someone else's work. Do not post progress narration — only facts others need.",
|
|
111
|
+
].join(" ");
|
|
112
|
+
}
|