@pify/swarm 0.4.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 +90 -9
- package/package.json +1 -1
- package/src/cancel.ts +104 -0
- package/src/pending.ts +86 -0
- package/src/types.ts +5 -1
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
|
@@ -25,6 +25,8 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
25
25
|
import { Type } from "typebox";
|
|
26
26
|
|
|
27
27
|
import { BUILTIN_AGENTS } from "../src/builtin.ts";
|
|
28
|
+
import { LiveChildren, cancelNote, type CancelReason } from "../src/cancel.ts";
|
|
29
|
+
import { DELIVERY_TYPE, deliveryMessage, pendingResult } from "../src/pending.ts";
|
|
28
30
|
import { createIsolationWorktree, isolationNote, removeIfUnchanged } from "../src/isolate.ts";
|
|
29
31
|
import { formatInbox, mailboxDir, mailboxPrompt, postMessage, readInbox } from "../src/mailbox.ts";
|
|
30
32
|
import { parseAgentFile } from "../src/frontmatter.ts";
|
|
@@ -77,6 +79,8 @@ function loadDefs(cwd: string, agentDir: string): Map<string, AgentDef> {
|
|
|
77
79
|
export default function swarm(pi: ExtensionAPI) {
|
|
78
80
|
let defs = new Map<string, AgentDef>();
|
|
79
81
|
const runs = new Map<string, SwarmRun>();
|
|
82
|
+
/** Live child sessions per run, so a stop actually reaches the children. */
|
|
83
|
+
const live = new LiveChildren();
|
|
80
84
|
let activeRun: SwarmRun | null = null;
|
|
81
85
|
let runCounter = 0;
|
|
82
86
|
let lastUiCtx: UiContext | null = null;
|
|
@@ -86,7 +90,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
86
90
|
lastUiCtx = ctx;
|
|
87
91
|
const run = activeRun;
|
|
88
92
|
const now = Date.now();
|
|
89
|
-
if (!run || (run.status
|
|
93
|
+
if (!run || (run.status !== "running" && (run.finishedAt ?? 0) < now - 15_000)) {
|
|
90
94
|
ctx.ui.setWidget("swarm", undefined);
|
|
91
95
|
return;
|
|
92
96
|
}
|
|
@@ -151,6 +155,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
151
155
|
|
|
152
156
|
async function runItem(
|
|
153
157
|
ctx: UiContext,
|
|
158
|
+
runId: string,
|
|
154
159
|
def: AgentDef,
|
|
155
160
|
item: ItemState,
|
|
156
161
|
context: string,
|
|
@@ -161,6 +166,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
161
166
|
renderWidget();
|
|
162
167
|
let session: AgentSession | null = null;
|
|
163
168
|
let unsubscribe: (() => void) | null = null;
|
|
169
|
+
let releaseLive: (() => void) | null = null;
|
|
164
170
|
try {
|
|
165
171
|
let model = ctx.model ?? null;
|
|
166
172
|
if (def.model) {
|
|
@@ -198,6 +204,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
198
204
|
}),
|
|
199
205
|
});
|
|
200
206
|
session = created.session;
|
|
207
|
+
releaseLive = live.register(runId, session);
|
|
201
208
|
|
|
202
209
|
unsubscribe = session.subscribe((event) => {
|
|
203
210
|
if (event.type === "message_end" && (event as { message?: { role?: string } }).message?.role === "assistant") {
|
|
@@ -232,6 +239,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
232
239
|
item.status = "error";
|
|
233
240
|
item.error = err instanceof Error ? err.message : String(err);
|
|
234
241
|
} finally {
|
|
242
|
+
if (releaseLive) releaseLive();
|
|
235
243
|
if (unsubscribe) {
|
|
236
244
|
try {
|
|
237
245
|
unsubscribe();
|
|
@@ -264,6 +272,9 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
264
272
|
const queue = [...run.items];
|
|
265
273
|
const workers = Array.from({ length: Math.min(DEFAULT_CONCURRENCY, queue.length) }, async () => {
|
|
266
274
|
for (;;) {
|
|
275
|
+
// A cancelled run stops taking new items; the ones already in flight
|
|
276
|
+
// were aborted by cancelRun.
|
|
277
|
+
if (run.status === "cancelled") return;
|
|
267
278
|
const item = queue.shift();
|
|
268
279
|
if (!item) return;
|
|
269
280
|
const def = routeItem(item.item, defs, fixed);
|
|
@@ -271,24 +282,43 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
271
282
|
if (isolate) {
|
|
272
283
|
try {
|
|
273
284
|
const iso = createIsolationWorktree(ctx.cwd, run.runId + "-i" + (item.index + 1));
|
|
274
|
-
await runItem(ctx, def, item, context, iso.path, mailbox);
|
|
285
|
+
await runItem(ctx, run.runId, def, item, context, iso.path, mailbox);
|
|
275
286
|
if (item.result !== null) item.result = `${item.result}\n\n${isolationNote(iso)}`;
|
|
276
287
|
} catch (err) {
|
|
277
288
|
item.status = "error";
|
|
278
289
|
item.error = err instanceof Error ? err.message : String(err);
|
|
279
290
|
}
|
|
280
291
|
} else {
|
|
281
|
-
await runItem(ctx, def, item, context, undefined, mailbox);
|
|
292
|
+
await runItem(ctx, run.runId, def, item, context, undefined, mailbox);
|
|
282
293
|
}
|
|
283
294
|
}
|
|
284
295
|
});
|
|
285
296
|
await Promise.all(workers);
|
|
286
|
-
run.status = "done";
|
|
297
|
+
if (run.status !== "cancelled") run.status = "done";
|
|
287
298
|
run.finishedAt = Date.now();
|
|
288
299
|
pi.appendEntry(RUN_ENTRY, run);
|
|
289
300
|
renderWidget();
|
|
290
301
|
}
|
|
291
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Stop a run and every child it started. Both meanings of "stop" — the
|
|
305
|
+
* user's abort and session teardown — come through here.
|
|
306
|
+
*/
|
|
307
|
+
function cancelRun(run: SwarmRun, reason: CancelReason): void {
|
|
308
|
+
const stopped = live.abortRun(run.runId);
|
|
309
|
+
if (run.status === "running") {
|
|
310
|
+
run.status = "cancelled";
|
|
311
|
+
run.finishedAt = Date.now();
|
|
312
|
+
}
|
|
313
|
+
for (const item of run.items) {
|
|
314
|
+
if (item.status === "running" || item.status === "queued") {
|
|
315
|
+
item.status = "aborted";
|
|
316
|
+
item.error = cancelNote(reason, stopped);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
renderWidget();
|
|
320
|
+
}
|
|
321
|
+
|
|
292
322
|
// ── Tools ────────────────────────────────────────────────────────────
|
|
293
323
|
|
|
294
324
|
pi.registerTool({
|
|
@@ -323,7 +353,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
323
353
|
isolation?: string;
|
|
324
354
|
mailbox?: boolean;
|
|
325
355
|
},
|
|
326
|
-
|
|
356
|
+
signal,
|
|
327
357
|
_onUpdate,
|
|
328
358
|
ctx,
|
|
329
359
|
) {
|
|
@@ -359,9 +389,36 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
359
389
|
activeRun = run;
|
|
360
390
|
renderWidget(uiCtx);
|
|
361
391
|
|
|
392
|
+
// Esc must reach the children. A background run outlives this tool call
|
|
393
|
+
// by design, so its signal is not its cancel button.
|
|
394
|
+
let stopListening: (() => void) | null = null;
|
|
395
|
+
if (signal && !run.background) {
|
|
396
|
+
const onAbort = () => cancelRun(run, "user-abort");
|
|
397
|
+
if (signal.aborted) onAbort();
|
|
398
|
+
else {
|
|
399
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
400
|
+
stopListening = () => signal.removeEventListener("abort", onAbort);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
362
404
|
if (run.background) {
|
|
363
405
|
void executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true).then(() => {
|
|
364
|
-
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
|
+
}
|
|
365
422
|
});
|
|
366
423
|
return {
|
|
367
424
|
content: [
|
|
@@ -371,7 +428,11 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
371
428
|
};
|
|
372
429
|
}
|
|
373
430
|
|
|
374
|
-
|
|
431
|
+
try {
|
|
432
|
+
await executeRun(uiCtx, run, params.context ?? "", params.agent, params.isolation === "worktree", params.mailbox === true);
|
|
433
|
+
} finally {
|
|
434
|
+
if (stopListening) stopListening();
|
|
435
|
+
}
|
|
375
436
|
return {
|
|
376
437
|
content: [{ type: "text", text: buildReport(run) }],
|
|
377
438
|
details: { runId: run.runId },
|
|
@@ -389,7 +450,23 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
389
450
|
async execute(_id, params: { runId?: string }) {
|
|
390
451
|
const run = params.runId ? runs.get(params.runId.trim()) : activeRun ?? [...runs.values()].pop();
|
|
391
452
|
if (!run) throw new Error("No swarm runs this session.");
|
|
392
|
-
|
|
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
|
+
}
|
|
463
|
+
const text =
|
|
464
|
+
run.status === "cancelled"
|
|
465
|
+
? "This run was cancelled before it finished. Below is what the items that did complete produced.\n" +
|
|
466
|
+
buildReport(run)
|
|
467
|
+
: run.status === "done"
|
|
468
|
+
? buildReport(run)
|
|
469
|
+
: buildStatusLine(run);
|
|
393
470
|
return { content: [{ type: "text", text }], details: { runId: run.runId, status: run.status } };
|
|
394
471
|
},
|
|
395
472
|
});
|
|
@@ -404,7 +481,7 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
404
481
|
const e = entry as { type?: string; customType?: string; data?: unknown };
|
|
405
482
|
if (e.type !== "custom" || e.customType !== RUN_ENTRY || !isRecord(e.data)) continue;
|
|
406
483
|
const run = e.data as unknown as SwarmRun;
|
|
407
|
-
if (typeof run.runId === "string" && run.status
|
|
484
|
+
if (typeof run.runId === "string" && run.status !== "running") {
|
|
408
485
|
runs.set(run.runId, run);
|
|
409
486
|
const n = Number.parseInt(run.runId.slice(1), 10);
|
|
410
487
|
if (Number.isFinite(n) && n > runCounter) runCounter = n;
|
|
@@ -414,6 +491,10 @@ export default function swarm(pi: ExtensionAPI) {
|
|
|
414
491
|
});
|
|
415
492
|
|
|
416
493
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
494
|
+
// A run cannot outlive the session that owns it.
|
|
495
|
+
for (const run of runs.values()) {
|
|
496
|
+
if (run.status === "running") cancelRun(run, "session-switch");
|
|
497
|
+
}
|
|
417
498
|
if (ctx.hasUI) ctx.ui.setWidget("swarm", undefined);
|
|
418
499
|
});
|
|
419
500
|
|
package/package.json
CHANGED
package/src/cancel.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stopping means stopping the children too.
|
|
3
|
+
*
|
|
4
|
+
* A run in this package is not one process: it is a tree of child agent
|
|
5
|
+
* sessions, each with its own provider connection. The tool that started them
|
|
6
|
+
* is handed an AbortSignal and the extension is told when the session goes
|
|
7
|
+
* away — and until now neither reached the children. Pressing Esc, or
|
|
8
|
+
* switching sessions with a run in flight, marked a record "aborted" while the
|
|
9
|
+
* children kept talking to the provider on the user's money, writing into a
|
|
10
|
+
* conversation nobody was reading.
|
|
11
|
+
*
|
|
12
|
+
* So every live child registers here, and the two places that mean "stop"
|
|
13
|
+
* abort all of them. (The rule is FradSer-adjacent prior art: @zhushanwen's
|
|
14
|
+
* subagent-workflow terminates running runs on session switch or shutdown
|
|
15
|
+
* rather than letting them outlive the session that owns them.)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The part of a child agent session this module needs. */
|
|
19
|
+
export interface Abortable {
|
|
20
|
+
abort(): unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type CancelReason = "user-abort" | "session-switch" | "timeout";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Live child sessions, grouped by the run that owns them. Registration
|
|
27
|
+
* returns its own release, so a child that finishes normally leaves no trace
|
|
28
|
+
* and cannot be aborted twice.
|
|
29
|
+
*/
|
|
30
|
+
export class LiveChildren {
|
|
31
|
+
private byRun = new Map<string, Set<Abortable>>();
|
|
32
|
+
|
|
33
|
+
register(runId: string, child: Abortable): () => void {
|
|
34
|
+
let set = this.byRun.get(runId);
|
|
35
|
+
if (!set) {
|
|
36
|
+
set = new Set();
|
|
37
|
+
this.byRun.set(runId, set);
|
|
38
|
+
}
|
|
39
|
+
set.add(child);
|
|
40
|
+
return () => {
|
|
41
|
+
const current = this.byRun.get(runId);
|
|
42
|
+
if (!current) return;
|
|
43
|
+
current.delete(child);
|
|
44
|
+
if (current.size === 0) this.byRun.delete(runId);
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** How many children of this run are still live. */
|
|
49
|
+
count(runId: string): number {
|
|
50
|
+
return this.byRun.get(runId)?.size ?? 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Total live children across every run. */
|
|
54
|
+
total(): number {
|
|
55
|
+
let sum = 0;
|
|
56
|
+
for (const set of this.byRun.values()) sum += set.size;
|
|
57
|
+
return sum;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Abort every live child of one run and return how many were stopped. A
|
|
62
|
+
* child that throws from abort() is still counted and still dropped: the
|
|
63
|
+
* point is that nothing is left holding a connection, and one stubborn
|
|
64
|
+
* child must not spare the others.
|
|
65
|
+
*/
|
|
66
|
+
abortRun(runId: string): number {
|
|
67
|
+
const set = this.byRun.get(runId);
|
|
68
|
+
if (!set) return 0;
|
|
69
|
+
let stopped = 0;
|
|
70
|
+
for (const child of [...set]) {
|
|
71
|
+
try {
|
|
72
|
+
const result = child.abort();
|
|
73
|
+
// abort() is async in pi; a rejection here is not ours to surface.
|
|
74
|
+
void Promise.resolve(result).catch(() => {});
|
|
75
|
+
} catch {
|
|
76
|
+
// already gone
|
|
77
|
+
}
|
|
78
|
+
stopped++;
|
|
79
|
+
}
|
|
80
|
+
this.byRun.delete(runId);
|
|
81
|
+
return stopped;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Abort every live child of every run. */
|
|
85
|
+
abortAll(): number {
|
|
86
|
+
let stopped = 0;
|
|
87
|
+
for (const runId of [...this.byRun.keys()]) stopped += this.abortRun(runId);
|
|
88
|
+
return stopped;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One line for the run log, naming who stopped it and what that cost. */
|
|
93
|
+
export function cancelNote(reason: CancelReason, stopped: number): string {
|
|
94
|
+
const children =
|
|
95
|
+
stopped === 0 ? "no child agents were running" : `${stopped} child agent${stopped === 1 ? "" : "s"} stopped`;
|
|
96
|
+
switch (reason) {
|
|
97
|
+
case "user-abort":
|
|
98
|
+
return `Cancelled by the user — ${children}. Work already finished is kept; the run itself did not complete.`;
|
|
99
|
+
case "session-switch":
|
|
100
|
+
return `The session went away, so the run was terminated — ${children}. Tokens already spent are not recoverable; start a new run for a result.`;
|
|
101
|
+
case "timeout":
|
|
102
|
+
return `The run exceeded its time limit — ${children}.`;
|
|
103
|
+
}
|
|
104
|
+
}
|
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";
|
package/src/types.ts
CHANGED
|
@@ -61,7 +61,11 @@ export interface ItemState {
|
|
|
61
61
|
error: string | null;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
/**
|
|
65
|
+
* "cancelled" is its own outcome, not a completion: someone stopped the run,
|
|
66
|
+
* and calling it done would report results nobody produced.
|
|
67
|
+
*/
|
|
68
|
+
export type RunStatus = "running" | "done" | "cancelled";
|
|
65
69
|
|
|
66
70
|
export interface SwarmRun {
|
|
67
71
|
runId: string;
|