agent-horde 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 +13 -2
- package/docs/REFERENCE.md +15 -3
- package/package.json +2 -2
- package/src/args.ts +30 -4
- package/src/dashboard-data.ts +31 -4
- package/src/dashboard-html.ts +4 -0
- package/src/format.ts +5 -0
- package/src/member.ts +22 -7
- package/src/prompt.ts +26 -6
- package/src/run/budget.ts +30 -0
- package/src/run/completions.ts +6 -1
- package/src/run/controls.ts +2 -1
- package/src/run/finalize.ts +5 -2
- package/src/run/launch.ts +2 -1
- package/src/run/repair.ts +2 -2
- package/src/run/setup.ts +3 -1
- package/src/run/supervise.ts +3 -2
- package/src/run/swarm-finalize.ts +3 -0
- package/src/run/swarm-report.ts +2 -1
- package/src/run/swarm.ts +2 -1
- package/src/run/types.ts +6 -0
- package/src/tool/actions.ts +1 -1
- package/src/tool/params.ts +14 -2
- package/src/widget.ts +12 -2
package/README.md
CHANGED
|
@@ -90,7 +90,7 @@ pi install npm:agent-horde
|
|
|
90
90
|
Or from the repository, pinned to a tag:
|
|
91
91
|
|
|
92
92
|
```
|
|
93
|
-
pi install git:github.com/eliyastein/agent-horde@v0.
|
|
93
|
+
pi install git:github.com/eliyastein/agent-horde@v0.3.0
|
|
94
94
|
```
|
|
95
95
|
|
|
96
96
|
To hack on it, clone and install in place. pi loads the files from the clone, so edits take effect
|
|
@@ -137,6 +137,16 @@ extend, stress-test, or deepen the work.
|
|
|
137
137
|
/agent-horde --dashboard --until-stop 8 find every way an unauthenticated request reaches the database
|
|
138
138
|
```
|
|
139
139
|
|
|
140
|
+
`--budget=<usd>` puts a dollar ceiling on a run: once the members' combined spend, counted across
|
|
141
|
+
every session they have had, reaches it, the parent performs a clean stop on its own and the
|
|
142
|
+
report says `stopped at budget`. It pairs with `--until-stop` to bound an open-ended run. The
|
|
143
|
+
spend counted is what the members' model calls report; the planner, deliverable and post-mortem
|
|
144
|
+
helpers are not included, and members still get the clean-stop grace period to wrap up.
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
/agent-horde --until-stop --budget=20 8 find every way an unauthenticated request reaches the database
|
|
148
|
+
```
|
|
149
|
+
|
|
140
150
|
A count of `H/M` runs a swarm: H hordes of M members on one task, at most four hordes. A planner
|
|
141
151
|
helper first splits the task into H disjoint workstreams with acceptance criteria and each horde
|
|
142
152
|
works only its slice, with its own ledgers under `.horde/swarms/<id>/hN/`; if the planner cannot
|
|
@@ -145,7 +155,8 @@ the planner and deals the layers round-robin to the hordes. Members read the oth
|
|
|
145
155
|
rule-outs and resources through the `horde` tool but write only to their own horde. The hordes
|
|
146
156
|
start a few seconds apart, run the normal lifecycle independently, and when the last one has
|
|
147
157
|
finished a synthesis helper merges their deliverables into the swarm's `DELIVERABLE.md`, each
|
|
148
|
-
claim attributed to its horde. `--until-stop` applies to every horde
|
|
158
|
+
claim attributed to its horde. `--until-stop` applies to every horde, and `--budget` is one ceiling
|
|
159
|
+
for all hordes combined.
|
|
149
160
|
|
|
150
161
|
```
|
|
151
162
|
/agent-horde --dashboard=1234 3/12 audit the service for authorization bugs
|
package/docs/REFERENCE.md
CHANGED
|
@@ -7,7 +7,7 @@ at one problem beat a single linear one: research, audits, design exploration, R
|
|
|
7
7
|
## Usage
|
|
8
8
|
|
|
9
9
|
```
|
|
10
|
-
/agent-horde [--dashboard[=<port>]] [--no-verifier] [--until-stop] <count> <prompt>
|
|
10
|
+
/agent-horde [--dashboard[=<port>]] [--no-verifier] [--until-stop] [--budget=<usd>] <count> <prompt>
|
|
11
11
|
/agent-horde plan [--dashboard] <count> <layer; layer; ...>
|
|
12
12
|
/agent-horde status [target]
|
|
13
13
|
/agent-horde stop-hard [target] SIGTERM, SIGKILL after 5s (alias: stop)
|
|
@@ -68,7 +68,14 @@ unfinished ideas return to the open pool.
|
|
|
68
68
|
A clean stop still produces the deliverable; add `postmortem` to also run the reviewer. Runs
|
|
69
69
|
that end on their own (all members finished, solved, canary or provider failure) get both.
|
|
70
70
|
`PI_HORDE_MAX_MINUTES` forces a clean stop at a wall-clock deadline, reported as
|
|
71
|
-
`stopped at deadline`.
|
|
71
|
+
`stopped at deadline`. `--budget=<usd>` forces one when the members' combined spend reaches that
|
|
72
|
+
many dollars, reported as `stopped at budget`; in a swarm the ceiling covers every horde and every
|
|
73
|
+
horde still working is stopped. The check runs on every usage update a member reports, so it fires
|
|
74
|
+
on the model call that crosses the line, during discovery or repair alike. The spend counted is the
|
|
75
|
+
members' own, summed over every session a member slot has had (resets, continuations and repair
|
|
76
|
+
attempts included); the planner, deliverable, post-mortem and merge helpers are not counted, and
|
|
77
|
+
the clean-stop grace period lets members finish, so the final total overshoots the ceiling by what
|
|
78
|
+
the wind-down costs.
|
|
72
79
|
|
|
73
80
|
Members and helper agents are children of the pi session. Quitting or reloading pi sends every
|
|
74
81
|
live child SIGTERM, and each child polls `PI_HORDE_PARENT_PID` every two seconds and exits on its
|
|
@@ -303,7 +310,11 @@ resets in the report. A session that finished within `PI_HORDE_CONTINUE_COOLDOWN
|
|
|
303
310
|
60000) of starting waits out the remainder first, so a member that finds nothing to do cannot spin.
|
|
304
311
|
Crash and stall resets apply as usual. `stop-clean` and `stop-hard` end the run as usual and
|
|
305
312
|
`stop-clean` produces the report, deliverable, and postmortem; the last banked answer stands unless
|
|
306
|
-
a later proposal was validated too.
|
|
313
|
+
a later proposal was validated too. `--budget=<usd>` bounds such a run by spend (see Stopping).
|
|
314
|
+
|
|
315
|
+
Usage totals in the widget, status, dashboard and reports are summed over every session each
|
|
316
|
+
member slot has had, so a reset or continuation never hides what the replaced session spent. The
|
|
317
|
+
per-session figures stay in the `member-exit` events and the per-member report sections.
|
|
307
318
|
|
|
308
319
|
## Horde of hordes
|
|
309
320
|
|
|
@@ -456,6 +467,7 @@ run/ the parent-side run lifecycle, sharing one HordeState
|
|
|
456
467
|
swarm-report.ts per-horde totals: the swarm REPORT.md and the injected message
|
|
457
468
|
launch.ts dashboard, deadline, partition, staggered canary preflights, fan-out
|
|
458
469
|
supervise.ts member spawn, reset on exit, stall detection
|
|
470
|
+
budget.ts `--budget`: swarm-wide spend check, clean stop when it is reached
|
|
459
471
|
schedule.ts artifact review assignment and superseded-dependency handoffs
|
|
460
472
|
repair.ts bounded post-discovery reviews, owner repair, and recheck attempts
|
|
461
473
|
solved.ts two-phase SOLVED poll
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-horde",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Run a horde of pi agents on one task: shared ledgers, peer review, an auditable event log, and a live dashboard",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"typebox": "*"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@earendil-works/pi-coding-agent": "1.2
|
|
53
|
+
"@earendil-works/pi-coding-agent": "1.5.2",
|
|
54
54
|
"@types/node": "26.6.5",
|
|
55
55
|
"oxlint": "1.88.0",
|
|
56
56
|
"typescript": "7.0.2"
|
package/src/args.ts
CHANGED
|
@@ -4,7 +4,7 @@ export const MAX_MEMBERS = 12;
|
|
|
4
4
|
export const MAX_HORDES = 4;
|
|
5
5
|
|
|
6
6
|
export const USAGE = [
|
|
7
|
-
"usage: /agent-horde [--dashboard[=<port>]] [--no-verifier] [--until-stop] <count> <prompt>",
|
|
7
|
+
"usage: /agent-horde [--dashboard[=<port>]] [--no-verifier] [--until-stop] [--budget=<usd>] <count> <prompt>",
|
|
8
8
|
" spawn <count> members with the same prompt;",
|
|
9
9
|
" <count> may be H/M: H hordes of M members, the",
|
|
10
10
|
" task partitioned between the hordes by a planner",
|
|
@@ -15,7 +15,7 @@ export const USAGE = [
|
|
|
15
15
|
" additionally run a post-mortem on the run",
|
|
16
16
|
" /agent-horde pause [target] suspend members (SIGSTOP)",
|
|
17
17
|
" /agent-horde resume [target] resume suspended members (SIGCONT)",
|
|
18
|
-
|
|
18
|
+
" /agent-horde plan [--dashboard[=<port>]] [--no-verifier] [--until-stop] [--budget=<usd>] <count> <a; b; ...>",
|
|
19
19
|
" partition members across workstreams; with H/M",
|
|
20
20
|
" the layers go round-robin to the hordes",
|
|
21
21
|
" /agent-horde help show this text",
|
|
@@ -30,6 +30,8 @@ export const USAGE = [
|
|
|
30
30
|
"--until-stop keeps the whole horde working until you run stop-clean or stop-hard: a",
|
|
31
31
|
"validated solve is banked as the current answer and a member that finishes gets a fresh",
|
|
32
32
|
"session told to add new ideas.",
|
|
33
|
+
"--budget=<usd> clean-stops the run (every horde of a swarm) once the members' combined",
|
|
34
|
+
"spend reaches that many dollars.",
|
|
33
35
|
].join("\n");
|
|
34
36
|
|
|
35
37
|
/** One token of a control target: a horde, a member in every horde, or a member of one horde. */
|
|
@@ -72,6 +74,8 @@ type HordeArgs =
|
|
|
72
74
|
noVerifier?: boolean | undefined;
|
|
73
75
|
/** Present (true) only when `--until-stop` was given. */
|
|
74
76
|
untilStop?: boolean | undefined;
|
|
77
|
+
/** Dollar spend at which the run clean-stops itself; present only when `--budget` was given. */
|
|
78
|
+
budget?: number | undefined;
|
|
75
79
|
};
|
|
76
80
|
|
|
77
81
|
export type HordeRunArgs = Extract<HordeArgs, { kind: "run" }>;
|
|
@@ -155,7 +159,9 @@ function parseStopRest(
|
|
|
155
159
|
const DASHBOARD_FLAG = /^--dashboard(?:=(\S*))?(?=\s|$)/;
|
|
156
160
|
const NO_VERIFIER_FLAG = /^--no-verifier(?=\s|$)/;
|
|
157
161
|
const UNTIL_STOP_FLAG = /^--until-stop(?=\s|$)/;
|
|
162
|
+
const BUDGET_FLAG = /^--budget(?:=(\S*)|\s+(\S+))?(?=\s|$)/;
|
|
158
163
|
const DASHBOARD_ERROR = "invalid --dashboard; use --dashboard or --dashboard=<port 1-65535>";
|
|
164
|
+
const BUDGET_ERROR = "invalid --budget; use --budget=<usd>, a positive dollar amount";
|
|
159
165
|
|
|
160
166
|
/** Launch flags found at one position of the command; `rest` is the remaining text. */
|
|
161
167
|
interface LaunchFlags {
|
|
@@ -163,6 +169,7 @@ interface LaunchFlags {
|
|
|
163
169
|
dashboard?: DashboardOption | undefined;
|
|
164
170
|
noVerifier?: boolean | undefined;
|
|
165
171
|
untilStop?: boolean | undefined;
|
|
172
|
+
budget?: number | undefined;
|
|
166
173
|
error?: string | undefined;
|
|
167
174
|
}
|
|
168
175
|
|
|
@@ -174,7 +181,14 @@ function parseDashboardFlag(port: string | undefined): DashboardOption | { error
|
|
|
174
181
|
return { port: value };
|
|
175
182
|
}
|
|
176
183
|
|
|
177
|
-
|
|
184
|
+
function parseBudgetFlag(amount: string | undefined): number | { error: string } {
|
|
185
|
+
if (amount === undefined || !/^\d+(?:\.\d+)?$/.test(amount)) return { error: BUDGET_ERROR };
|
|
186
|
+
const value = Number(amount);
|
|
187
|
+
if (value <= 0) return { error: BUDGET_ERROR };
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Strip leading `--dashboard[=<port>]`, `--no-verifier`, `--until-stop` and `--budget` tokens, in any order. */
|
|
178
192
|
function takeLaunchFlags(text: string): LaunchFlags {
|
|
179
193
|
const flags: LaunchFlags = { rest: text.trim() };
|
|
180
194
|
for (;;) {
|
|
@@ -186,6 +200,14 @@ function takeLaunchFlags(text: string): LaunchFlags {
|
|
|
186
200
|
flags.rest = flags.rest.slice(dashboard[0].length).trim();
|
|
187
201
|
continue;
|
|
188
202
|
}
|
|
203
|
+
const budget = BUDGET_FLAG.exec(flags.rest);
|
|
204
|
+
if (budget) {
|
|
205
|
+
const value = parseBudgetFlag(budget[1] ?? budget[2]);
|
|
206
|
+
if (typeof value !== "number") return { ...flags, error: value.error };
|
|
207
|
+
flags.budget = value;
|
|
208
|
+
flags.rest = flags.rest.slice(budget[0].length).trim();
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
189
211
|
const noVerifier = NO_VERIFIER_FLAG.exec(flags.rest);
|
|
190
212
|
if (noVerifier) {
|
|
191
213
|
flags.noVerifier = true;
|
|
@@ -209,6 +231,7 @@ function mergeLaunchFlags(leading: LaunchFlags, trailing: LaunchFlags): LaunchFl
|
|
|
209
231
|
dashboard: leading.dashboard ?? trailing.dashboard,
|
|
210
232
|
noVerifier: leading.noVerifier === true || trailing.noVerifier === true ? true : undefined,
|
|
211
233
|
untilStop: leading.untilStop === true || trailing.untilStop === true ? true : undefined,
|
|
234
|
+
budget: leading.budget ?? trailing.budget,
|
|
212
235
|
error: leading.error ?? trailing.error,
|
|
213
236
|
};
|
|
214
237
|
}
|
|
@@ -242,6 +265,7 @@ function runArgs(parsed: CountedRun, prompt: string, plan?: string[]): HordeRunA
|
|
|
242
265
|
if (flags.dashboard) args.dashboard = flags.dashboard;
|
|
243
266
|
if (flags.noVerifier) args.noVerifier = true;
|
|
244
267
|
if (flags.untilStop) args.untilStop = true;
|
|
268
|
+
if (flags.budget !== undefined) args.budget = flags.budget;
|
|
245
269
|
return args;
|
|
246
270
|
}
|
|
247
271
|
|
|
@@ -269,7 +293,9 @@ export function parseHordeArgs(raw: string): HordeArgs {
|
|
|
269
293
|
const leading = takeLaunchFlags(raw);
|
|
270
294
|
if (leading.error) return { kind: "usage", error: leading.error };
|
|
271
295
|
const trimmed = leading.rest;
|
|
272
|
-
const hasFlags =
|
|
296
|
+
const hasFlags =
|
|
297
|
+
leading.dashboard !== undefined || leading.noVerifier === true || leading.untilStop === true ||
|
|
298
|
+
leading.budget !== undefined;
|
|
273
299
|
if (trimmed === "") {
|
|
274
300
|
return hasFlags ? { kind: "usage", error: "count is required" } : { kind: "usage" };
|
|
275
301
|
}
|
package/src/dashboard-data.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { buildRunGraph } from "./run-graph.js";
|
|
|
26
26
|
import { buildSwarmGraph } from "./swarm-graph.js";
|
|
27
27
|
import type { RunGraph } from "./run-graph.js";
|
|
28
28
|
import { readCleanStop } from "./ledger/run-signals.js";
|
|
29
|
-
import { memberSessionDir, parseMemberLog } from "./member.js";
|
|
29
|
+
import { emptyUsage, memberSessionDir, parseMemberLog, USAGE_KEYS } from "./member.js";
|
|
30
30
|
import type { MemberEndCause, MemberState, MemberStatus, MemberUsage } from "./member.js";
|
|
31
31
|
import { memberActivity, parseReportOutcome, parseTaskMarkdown, statusCounts, totalUsage } from "./prompt.js";
|
|
32
32
|
import type { RunSummaryInput, UsageTotals } from "./prompt.js";
|
|
@@ -72,6 +72,7 @@ export interface LiveSwarm {
|
|
|
72
72
|
runs: LiveRun[];
|
|
73
73
|
partition?: string[] | undefined;
|
|
74
74
|
untilStop?: boolean | undefined;
|
|
75
|
+
budget?: number | undefined;
|
|
75
76
|
/** The swarm step still running after every horde finalized. */
|
|
76
77
|
finalizing?: string[] | undefined;
|
|
77
78
|
deliverable?: string | undefined;
|
|
@@ -383,6 +384,7 @@ interface SwarmRecordOnDisk {
|
|
|
383
384
|
count?: number;
|
|
384
385
|
startedAt?: number;
|
|
385
386
|
untilStop?: boolean;
|
|
387
|
+
budget?: number;
|
|
386
388
|
partition?: string[];
|
|
387
389
|
runs?: Array<{ label?: string; runId?: string; startedAt?: number; endedAt?: number; outcome?: string }>;
|
|
388
390
|
merge?: { startedAt?: number; endedAt?: number; written?: boolean };
|
|
@@ -500,7 +502,7 @@ function liveMemberRow(m: MemberState, now: number, runDir: string): MemberRowVm
|
|
|
500
502
|
paused: m.paused === true,
|
|
501
503
|
turns: m.turns,
|
|
502
504
|
toolCalls: m.toolCalls,
|
|
503
|
-
usage: { ...m.
|
|
505
|
+
usage: { ...m.lifetime },
|
|
504
506
|
elapsedMs: (m.endedAt ?? now) - m.startedAt,
|
|
505
507
|
endCause: m.endCause,
|
|
506
508
|
partial: m.partial === true,
|
|
@@ -521,10 +523,28 @@ function safeSessionStats(filePath: string): SessionStats | undefined {
|
|
|
521
523
|
}
|
|
522
524
|
}
|
|
523
525
|
|
|
526
|
+
/** Stats over every session file of a member (resets and continuations leave one each); oversized files are skipped. */
|
|
527
|
+
function memberSessionStats(runDir: string, name: string): SessionStats | undefined {
|
|
528
|
+
const dir = memberSessionDir(runDir, name);
|
|
529
|
+
const all = listSessionFiles(dir).map((file) => safeSessionStats(path.join(dir, file.name)));
|
|
530
|
+
const parts = all.filter((stats): stats is SessionStats => stats !== undefined);
|
|
531
|
+
if (parts.length === 0) return undefined;
|
|
532
|
+
const total: SessionStats = { entries: 0, turns: 0, toolCalls: 0, usage: emptyUsage() };
|
|
533
|
+
for (const stats of parts) {
|
|
534
|
+
total.entries += stats.entries;
|
|
535
|
+
total.turns += stats.turns;
|
|
536
|
+
total.toolCalls += stats.toolCalls;
|
|
537
|
+
for (const key of USAGE_KEYS) total.usage[key] += stats.usage[key];
|
|
538
|
+
if (stats.first && (!total.first || stats.first < total.first)) total.first = stats.first;
|
|
539
|
+
if (stats.last && (!total.last || stats.last > total.last)) total.last = stats.last;
|
|
540
|
+
}
|
|
541
|
+
return total;
|
|
542
|
+
}
|
|
543
|
+
|
|
524
544
|
function diskMemberRow(runDir: string, name: string): MemberRowVm {
|
|
525
545
|
const log = parseMemberLog(readOptional(path.join(runDir, `${name}.log`)) ?? "");
|
|
526
546
|
const sessionFile = memberSessionFile(runDir, name);
|
|
527
|
-
const stats =
|
|
547
|
+
const stats = memberSessionStats(runDir, name);
|
|
528
548
|
const elapsedMs =
|
|
529
549
|
stats?.first && stats.last ? Date.parse(stats.last) - Date.parse(stats.first) : undefined;
|
|
530
550
|
return {
|
|
@@ -535,7 +555,7 @@ function diskMemberRow(runDir: string, name: string): MemberRowVm {
|
|
|
535
555
|
paused: false,
|
|
536
556
|
turns: log.turns ?? stats?.turns ?? 0,
|
|
537
557
|
toolCalls: log.toolCalls ?? stats?.toolCalls ?? 0,
|
|
538
|
-
usage: stats?.usage ??
|
|
558
|
+
usage: stats?.usage ?? emptyUsage(),
|
|
539
559
|
elapsedMs,
|
|
540
560
|
endCause: log.endCause,
|
|
541
561
|
partial: log.partial,
|
|
@@ -572,6 +592,8 @@ export interface RunOverviewVm {
|
|
|
572
592
|
outcome?: string | undefined;
|
|
573
593
|
stopLabel?: string | undefined;
|
|
574
594
|
cleanStop?: { secondsLeft?: number | undefined; members: string[] } | undefined;
|
|
595
|
+
/** `--budget` in dollars; the header shows `totals.cost` against it. */
|
|
596
|
+
budget?: number | undefined;
|
|
575
597
|
plan?: string[] | undefined;
|
|
576
598
|
canary?: string | undefined;
|
|
577
599
|
canaryError?: string | undefined;
|
|
@@ -617,6 +639,7 @@ function liveOverview(src: DashboardSource, live: LiveRun): RunOverviewVm {
|
|
|
617
639
|
outcome: liveOutcome(live),
|
|
618
640
|
stopLabel: live.stopLabel,
|
|
619
641
|
cleanStop: liveCleanStop(live, now),
|
|
642
|
+
budget: live.budget,
|
|
620
643
|
plan: live.plan,
|
|
621
644
|
canary: live.canary,
|
|
622
645
|
canaryError: live.canaryError,
|
|
@@ -658,6 +681,7 @@ function diskOverview(runId: string, runDir: string): RunOverviewVm {
|
|
|
658
681
|
elapsedMs: elapsed.length > 0 ? Math.max(...elapsed) : undefined,
|
|
659
682
|
outcome: runStateOnDisk(runDir) === "pending" ? "pending" : diskOutcome(runDir),
|
|
660
683
|
cleanStop: cleanStop ? { members: cleanStop.members } : undefined,
|
|
684
|
+
budget: task.budget,
|
|
661
685
|
plan: readOptional(path.join(runDir, "PLAN.md"))
|
|
662
686
|
?.split("\n")
|
|
663
687
|
.filter((line) => line.startsWith("- layer ")),
|
|
@@ -1095,6 +1119,8 @@ export interface SwarmVm {
|
|
|
1095
1119
|
started?: string | undefined;
|
|
1096
1120
|
elapsedMs?: number | undefined;
|
|
1097
1121
|
untilStop: boolean;
|
|
1122
|
+
/** `--budget` in dollars across every horde; the header shows the hordes' summed cost against it. */
|
|
1123
|
+
budget?: number | undefined;
|
|
1098
1124
|
partition: string[];
|
|
1099
1125
|
/** What the merge step is doing or did: "pending", "merging", "merged", "no deliverable". */
|
|
1100
1126
|
merge: string;
|
|
@@ -1175,6 +1201,7 @@ export function loadSwarm(src: DashboardSource, swarmId: string): SwarmVm {
|
|
|
1175
1201
|
started: startedAt !== undefined ? formatDateTime(new Date(startedAt)) : undefined,
|
|
1176
1202
|
elapsedMs: startedAt !== undefined ? (endedAt ?? nowOf(src)) - startedAt : undefined,
|
|
1177
1203
|
untilStop: live?.untilStop === true || record?.untilStop === true,
|
|
1204
|
+
budget: live?.budget ?? record?.budget,
|
|
1178
1205
|
partition,
|
|
1179
1206
|
merge: mergeLabel(live, record, rows),
|
|
1180
1207
|
rows,
|
package/src/dashboard-html.ts
CHANGED
|
@@ -456,6 +456,7 @@ function overviewHeader(vm: RunOverviewVm): string {
|
|
|
456
456
|
const left = vm.cleanStop.secondsLeft === undefined ? "" : `hard stop in ${vm.cleanStop.secondsLeft}s: `;
|
|
457
457
|
rows.push(["clean stop", e(`${left}${who}`)]);
|
|
458
458
|
}
|
|
459
|
+
if (vm.budget !== undefined) rows.push(["budget", `${cost(vm.totals.cost)} of ${cost(vm.budget)} spent`]);
|
|
459
460
|
if (vm.canary) rows.push(["canary", e(vm.canary + (vm.canaryError ? ` (${vm.canaryError})` : ""))]);
|
|
460
461
|
if (vm.diagnosis) rows.push(["diagnosis", e(vm.diagnosis)]);
|
|
461
462
|
if (vm.plan && vm.plan.length > 0) rows.push(["plan", pre(vm.plan.join("\n"))]);
|
|
@@ -981,6 +982,9 @@ function swarmHeader(vm: SwarmVm): string {
|
|
|
981
982
|
["until stop", vm.untilStop ? "yes" : "no"],
|
|
982
983
|
["merge", e(vm.merge)],
|
|
983
984
|
];
|
|
985
|
+
if (vm.budget !== undefined) {
|
|
986
|
+
rows.push(["budget", `${cost(vm.rows.reduce((sum, row) => sum + row.totals.cost, 0))} of ${cost(vm.budget)} spent`]);
|
|
987
|
+
}
|
|
984
988
|
if (vm.notStarted) rows.push(["not started", e(vm.notStarted)]);
|
|
985
989
|
if (vm.partition.length > 0) {
|
|
986
990
|
rows.push(["partition", pre(vm.partition.map((slice, index) => `h${index + 1}: ${slice}`).join("\n"))]);
|
package/src/format.ts
CHANGED
|
@@ -43,6 +43,11 @@ export function formatTokens(n: number): string {
|
|
|
43
43
|
return `${(n / 1_000_000).toFixed(2)}M`;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/** A dollar amount for the widget, status and reports, e.g. `$1.20`. */
|
|
47
|
+
export function formatUsd(amount: number): string {
|
|
48
|
+
return `$${amount.toFixed(2)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
46
51
|
export function oneLine(text: string, max: number): string {
|
|
47
52
|
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
48
53
|
if (collapsed.length <= max) return collapsed;
|
package/src/member.ts
CHANGED
|
@@ -35,7 +35,10 @@ export interface MemberState {
|
|
|
35
35
|
streaming?: "text" | "thinking" | undefined;
|
|
36
36
|
turns: number;
|
|
37
37
|
toolCalls: number;
|
|
38
|
+
/** Usage of the current session; zeroed by every reset, continuation and repair attempt. */
|
|
38
39
|
usage: MemberUsage;
|
|
40
|
+
/** Usage across every session of this member slot; never reset. Totals and the budget read it. */
|
|
41
|
+
lifetime: MemberUsage;
|
|
39
42
|
lastText: string;
|
|
40
43
|
finalText: string;
|
|
41
44
|
/** In-progress assistant text retained if termination interrupts the final message. */
|
|
@@ -120,6 +123,7 @@ interface SpawnMemberInput {
|
|
|
120
123
|
onFirstOutput?: () => void;
|
|
121
124
|
}
|
|
122
125
|
|
|
126
|
+
export const USAGE_KEYS = ["input", "output", "cacheRead", "cacheWrite", "cost"] as const;
|
|
123
127
|
const LAST_TEXT_CHARS = 80;
|
|
124
128
|
const STDERR_TAIL_LINES = 20;
|
|
125
129
|
const KILL_GRACE_MS = 5_000;
|
|
@@ -254,13 +258,18 @@ export function newMemberState(index: number): MemberState {
|
|
|
254
258
|
status: "starting",
|
|
255
259
|
turns: 0,
|
|
256
260
|
toolCalls: 0,
|
|
257
|
-
usage:
|
|
261
|
+
usage: emptyUsage(),
|
|
262
|
+
lifetime: emptyUsage(),
|
|
258
263
|
lastText: "",
|
|
259
264
|
finalText: "",
|
|
260
265
|
startedAt: Date.now(),
|
|
261
266
|
};
|
|
262
267
|
}
|
|
263
268
|
|
|
269
|
+
export function emptyUsage(): MemberUsage {
|
|
270
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
271
|
+
}
|
|
272
|
+
|
|
264
273
|
/** Reset a member to its initial state so it can be respawned (canary retry, stall reset). */
|
|
265
274
|
export function resetMemberState(member: MemberState): void {
|
|
266
275
|
member.status = "starting";
|
|
@@ -268,7 +277,7 @@ export function resetMemberState(member: MemberState): void {
|
|
|
268
277
|
member.attempt = randomUUID();
|
|
269
278
|
member.turns = 0;
|
|
270
279
|
member.toolCalls = 0;
|
|
271
|
-
member.usage =
|
|
280
|
+
member.usage = emptyUsage();
|
|
272
281
|
member.lastText = "";
|
|
273
282
|
member.finalText = "";
|
|
274
283
|
member.draftText = undefined;
|
|
@@ -419,11 +428,17 @@ function applyAssistantMessageEnd(member: MemberState, message: Record<string, u
|
|
|
419
428
|
member.streaming = undefined;
|
|
420
429
|
const usage = (message.usage ?? {}) as Record<string, unknown>;
|
|
421
430
|
const num = (v: unknown): number => (typeof v === "number" ? v : 0);
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
431
|
+
const delta: MemberUsage = {
|
|
432
|
+
input: num(usage.input),
|
|
433
|
+
output: num(usage.output),
|
|
434
|
+
cacheRead: num(usage.cacheRead),
|
|
435
|
+
cacheWrite: num(usage.cacheWrite),
|
|
436
|
+
cost: num((usage.cost as Record<string, unknown> | undefined)?.total),
|
|
437
|
+
};
|
|
438
|
+
for (const key of USAGE_KEYS) {
|
|
439
|
+
member.usage[key] += delta[key];
|
|
440
|
+
member.lifetime[key] += delta[key];
|
|
441
|
+
}
|
|
427
442
|
const text = messageText(message);
|
|
428
443
|
if (text.trim()) {
|
|
429
444
|
member.finalText = text;
|
package/src/prompt.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatClock, formatDateTime, formatElapsed, formatTokens, oneLine, taskHash } from "./format.js";
|
|
1
|
+
import { formatClock, formatDateTime, formatElapsed, formatTokens, formatUsd, oneLine, taskHash } from "./format.js";
|
|
2
2
|
import type { BoardEntry } from "./ledger/board.js";
|
|
3
3
|
import type { ClaimEntry } from "./ledger/claims.js";
|
|
4
4
|
import type { FindingCluster } from "./ledger/clusters.js";
|
|
@@ -26,6 +26,8 @@ export interface RunSummaryInput {
|
|
|
26
26
|
userStop?: boolean | undefined;
|
|
27
27
|
/** Human-readable stop reason, e.g. "stopped by user" or "stopped at deadline". */
|
|
28
28
|
stopLabel?: string | undefined;
|
|
29
|
+
/** The `--budget` in dollars the run (or its swarm) clean-stops at. */
|
|
30
|
+
budget?: number | undefined;
|
|
29
31
|
/** Set when a member reported the goal solved; the end sentence states whether the run wound down on it. */
|
|
30
32
|
solved?: SolvedSignal | undefined;
|
|
31
33
|
/** The other member that validated the proposal, if any. */
|
|
@@ -299,10 +301,16 @@ export function taskMarkdown(input: {
|
|
|
299
301
|
startedAt: number;
|
|
300
302
|
verifier?: string | undefined;
|
|
301
303
|
untilStop?: boolean | undefined;
|
|
304
|
+
/** `--budget` in dollars; a swarm's budget covers every horde. */
|
|
305
|
+
budget?: number | undefined;
|
|
302
306
|
/** Set for a horde of a larger swarm: which horde this is and its workstream. */
|
|
303
307
|
swarm?: { id: string; horde: number; hordes: number; slice?: string | undefined } | undefined;
|
|
304
308
|
}): string {
|
|
305
309
|
const swarm = input.swarm;
|
|
310
|
+
const budget =
|
|
311
|
+
input.budget === undefined
|
|
312
|
+
? "none"
|
|
313
|
+
: `${formatUsd(input.budget)} (the run clean-stops when member spend${swarm ? " across the swarm" : ""} reaches it)`;
|
|
306
314
|
return [
|
|
307
315
|
`# Horde run ${input.id}`,
|
|
308
316
|
"",
|
|
@@ -312,6 +320,7 @@ export function taskMarkdown(input: {
|
|
|
312
320
|
`Thinking: ${input.thinkingLevel ?? "parent default"}`,
|
|
313
321
|
`Verifier: ${input.verifier ?? "none"}`,
|
|
314
322
|
`Until stop: ${input.untilStop ? "yes (the run continues until stop-clean or stop-hard)" : "no"}`,
|
|
323
|
+
`Budget: ${budget}`,
|
|
315
324
|
...(swarm ? [`Swarm: ${swarm.id} (horde h${swarm.horde} of ${swarm.hordes})`] : []),
|
|
316
325
|
...(swarm?.slice ? [`Workstream: ${swarm.slice}`] : []),
|
|
317
326
|
`Task-SHA256: ${taskHash(input.prompt)}`,
|
|
@@ -331,6 +340,8 @@ interface TaskInfo {
|
|
|
331
340
|
model?: string | undefined;
|
|
332
341
|
thinking?: string | undefined;
|
|
333
342
|
hash?: string | undefined;
|
|
343
|
+
/** The `--budget` in dollars, when the run was started with one. */
|
|
344
|
+
budget?: number | undefined;
|
|
334
345
|
prompt: string;
|
|
335
346
|
}
|
|
336
347
|
|
|
@@ -342,13 +353,17 @@ export function parseTaskMarkdown(content: string): TaskInfo {
|
|
|
342
353
|
for (const line of lines.slice(0, promptAt === -1 ? lines.length : promptAt)) {
|
|
343
354
|
const title = /^# Horde run (\S+)$/.exec(line);
|
|
344
355
|
if (title) info.id = title[1];
|
|
345
|
-
const field = /^(Started|Members|Model|Thinking|Task-SHA256): (.*)$/.exec(line);
|
|
356
|
+
const field = /^(Started|Members|Model|Thinking|Budget|Task-SHA256): (.*)$/.exec(line);
|
|
346
357
|
if (!field) continue;
|
|
347
358
|
const value = field[2] ?? "";
|
|
348
359
|
if (field[1] === "Started") info.started = value;
|
|
349
360
|
else if (field[1] === "Members") info.count = Number(/^\d+/.exec(value)?.[0] ?? NaN) || undefined;
|
|
350
361
|
else if (field[1] === "Model") info.model = value;
|
|
351
362
|
else if (field[1] === "Thinking") info.thinking = value;
|
|
363
|
+
else if (field[1] === "Budget") {
|
|
364
|
+
const amount = Number(/^\$(\d+(?:\.\d+)?)/.exec(value)?.[1] ?? NaN);
|
|
365
|
+
if (amount > 0) info.budget = amount;
|
|
366
|
+
}
|
|
352
367
|
else if (field[1] === "Task-SHA256") info.hash = value;
|
|
353
368
|
}
|
|
354
369
|
return info;
|
|
@@ -374,13 +389,14 @@ export interface UsageTotals {
|
|
|
374
389
|
cost: number;
|
|
375
390
|
}
|
|
376
391
|
|
|
392
|
+
/** Run totals over every session each member has had; turns count the current sessions only. */
|
|
377
393
|
export function totalUsage(members: MemberState[]): UsageTotals {
|
|
378
394
|
return members.reduce(
|
|
379
395
|
(acc, m) => ({
|
|
380
396
|
turns: acc.turns + m.turns,
|
|
381
|
-
input: acc.input + m.
|
|
382
|
-
output: acc.output + m.
|
|
383
|
-
cost: acc.cost + m.
|
|
397
|
+
input: acc.input + m.lifetime.input,
|
|
398
|
+
output: acc.output + m.lifetime.output,
|
|
399
|
+
cost: acc.cost + m.lifetime.cost,
|
|
384
400
|
}),
|
|
385
401
|
{ turns: 0, input: 0, output: 0, cost: 0 },
|
|
386
402
|
);
|
|
@@ -586,6 +602,9 @@ export function formatStatusReport(run: RunSummaryInput, now: number = Date.now(
|
|
|
586
602
|
const left = Math.max(0, Math.ceil((run.cleanStopUntil - now) / 1000));
|
|
587
603
|
lines.push(`clean stop requested: members wrap up now, hard stop in ${left}s`);
|
|
588
604
|
}
|
|
605
|
+
if (run.budget !== undefined) {
|
|
606
|
+
lines.push(`budget: ${formatUsd(totalUsage(run.members).cost)} of ${formatUsd(run.budget)} spent`);
|
|
607
|
+
}
|
|
589
608
|
for (const m of run.members) {
|
|
590
609
|
lines.push(
|
|
591
610
|
`${m.name.padEnd(10)} ${m.status.padEnd(8)} ${memberActivity(m)} ` +
|
|
@@ -653,7 +672,8 @@ function reportHeader(input: RunReportInput, now: number): string[] {
|
|
|
653
672
|
if (input.model) lines.push(`Model: ${input.model}`);
|
|
654
673
|
lines.push(
|
|
655
674
|
`Usage: ${usage.turns} turns, ${formatTokens(usage.input)} in / ` +
|
|
656
|
-
`${formatTokens(usage.output)} out, $${usage.cost.toFixed(4)} cost
|
|
675
|
+
`${formatTokens(usage.output)} out, $${usage.cost.toFixed(4)} cost` +
|
|
676
|
+
(input.budget === undefined ? "" : ` (budget ${formatUsd(input.budget)})`),
|
|
657
677
|
);
|
|
658
678
|
return lines;
|
|
659
679
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { formatUsd } from "../format.js";
|
|
3
|
+
import { totalUsage } from "../prompt.js";
|
|
4
|
+
import { requestCleanStop } from "./controls.js";
|
|
5
|
+
import type { HordeState, Swarm } from "./types.js";
|
|
6
|
+
|
|
7
|
+
/** Dollars the swarm's members have spent over every session, replaced ones included. */
|
|
8
|
+
export function swarmSpend(swarm: Pick<Swarm, "runs">): number {
|
|
9
|
+
return swarm.runs.reduce((sum, run) => sum + totalUsage(run.members).cost, 0);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `--budget`: once the members' combined spend reaches the threshold, clean-stop every horde
|
|
14
|
+
* still working. Called on every child event, so it fires on the usage update that crosses the
|
|
15
|
+
* line. The flag is set before the first await so a burst of events requests the stop once.
|
|
16
|
+
* Hordes that already reported are skipped: a clean stop on one would re-arm a timer that
|
|
17
|
+
* `stopRunTimers` cleared and record a stop-hard on a finished run.
|
|
18
|
+
*/
|
|
19
|
+
export function checkBudget(launch: { state: HordeState; swarm: Swarm; ctx: ExtensionCommandContext }): void {
|
|
20
|
+
const { state, swarm, ctx } = launch;
|
|
21
|
+
if (swarm.budget === undefined || swarm.budgetHit || state.disposed) return;
|
|
22
|
+
const spent = swarmSpend(swarm);
|
|
23
|
+
if (spent < swarm.budget) return;
|
|
24
|
+
swarm.budgetHit = true;
|
|
25
|
+
ctx.ui.notify(`horde ${swarm.id}: budget reached (${formatUsd(spent)} of ${formatUsd(swarm.budget)}); stopping`, "warning");
|
|
26
|
+
for (const run of swarm.runs) {
|
|
27
|
+
if (run.stopped || run.reported || run.finalized) continue;
|
|
28
|
+
void requestCleanStop(run, { kind: "all" }, ctx, { postmortem: false, label: "budget" });
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/run/completions.ts
CHANGED
|
@@ -31,7 +31,12 @@ const UNTIL_STOP_COMPLETION = {
|
|
|
31
31
|
description: "keep the whole horde working until stop-clean or stop-hard; validated solves are banked",
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
const
|
|
34
|
+
const BUDGET_COMPLETION = {
|
|
35
|
+
value: "--budget=5 ",
|
|
36
|
+
description: "clean-stop the run once the members' combined spend reaches this many dollars; edit the number",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const LAUNCH_FLAG_COMPLETIONS = [DASHBOARD_COMPLETION, NO_VERIFIER_COMPLETION, UNTIL_STOP_COMPLETION, BUDGET_COMPLETION];
|
|
35
40
|
|
|
36
41
|
/** Argument completions that are not member targets. */
|
|
37
42
|
const EXTRA_COMPLETIONS: Record<string, Array<{ value: string; description: string }>> = {
|
package/src/run/controls.ts
CHANGED
|
@@ -19,7 +19,7 @@ const CLEAN_STOP_ACTIVE_GRACE_MS = Number(process.env.PI_HORDE_ACTIVE_GRACE_MS)
|
|
|
19
19
|
const SOLVED_STOP_MS = Number(process.env.PI_HORDE_SOLVED_STOP_MS) || 30_000;
|
|
20
20
|
|
|
21
21
|
/** Why a clean stop was requested; selects the report's stop label. */
|
|
22
|
-
type CleanStopLabel = "stop-clean" | "deadline" | "solved";
|
|
22
|
+
type CleanStopLabel = "stop-clean" | "deadline" | "solved" | "budget";
|
|
23
23
|
|
|
24
24
|
interface CleanStopOptions {
|
|
25
25
|
graceMs?: number | undefined;
|
|
@@ -168,6 +168,7 @@ export function setPaused(
|
|
|
168
168
|
|
|
169
169
|
function stopLabelFor(label: CleanStopLabel): string {
|
|
170
170
|
if (label === "deadline") return "stopped at deadline";
|
|
171
|
+
if (label === "budget") return "stopped at budget";
|
|
171
172
|
if (label === "solved") return "stopped during solved wind-down";
|
|
172
173
|
return "stopped by user";
|
|
173
174
|
}
|
package/src/run/finalize.ts
CHANGED
|
@@ -29,7 +29,7 @@ import type { PendingCleanup } from "../ledger/resources.js";
|
|
|
29
29
|
import { archiveSolved, clearCleanStop, clearSolved, readSolvedState, resolveSolvedState, writeAnswer } from "../ledger/run-signals.js";
|
|
30
30
|
import type { SolvedSignal } from "../ledger/run-signals.js";
|
|
31
31
|
import { childEnv, execPi } from "../member.js";
|
|
32
|
-
import { reportText, runReportMarkdown, summaryMarkdown } from "../prompt.js";
|
|
32
|
+
import { reportText, runReportMarkdown, summaryMarkdown, totalUsage } from "../prompt.js";
|
|
33
33
|
import { buildRunGraph } from "../run-graph.js";
|
|
34
34
|
import { runGraphMarkdown } from "../run-graph-mermaid.js";
|
|
35
35
|
import type { CleanupStatus, ReportResource, RunReportInput } from "../prompt.js";
|
|
@@ -569,7 +569,10 @@ export async function finalizeRun(state: HordeState, swarm: Swarm, run: HordeRun
|
|
|
569
569
|
await synthesize(state, run, ledgers, reportInput, now);
|
|
570
570
|
if (annotate && run.deliverable) annotateAnswer(run, answer, acceptance, stale);
|
|
571
571
|
sendReport(state, swarm, run, ledgers, now);
|
|
572
|
-
await recordLifecycle(run, "run-end", {
|
|
572
|
+
await recordLifecycle(run, "run-end", {
|
|
573
|
+
outcome: runOutcome(run), elapsedMs: Date.now() - run.startedAt, stopLabel: run.stopLabel,
|
|
574
|
+
spent: totalUsage(run.members).cost, budget: run.budget,
|
|
575
|
+
});
|
|
573
576
|
writeRunGraph(run);
|
|
574
577
|
} catch (error) {
|
|
575
578
|
safeNotify(run, `horde ${run.id} failed to finalize: ${errorMessage(error)}`, "error");
|
package/src/run/launch.ts
CHANGED
|
@@ -277,6 +277,7 @@ function buildSwarm(
|
|
|
277
277
|
plan: parsed.plan,
|
|
278
278
|
partition: partition.slices,
|
|
279
279
|
untilStop: parsed.untilStop === true ? true : undefined,
|
|
280
|
+
budget: parsed.budget,
|
|
280
281
|
dashboardUrl,
|
|
281
282
|
};
|
|
282
283
|
return { swarm, launches };
|
|
@@ -287,7 +288,7 @@ async function seedHorde(swarm: Swarm, run: HordeRun, roles: string[]): Promise<
|
|
|
287
288
|
await seedRun(run);
|
|
288
289
|
await recordLifecycle(run, "run-start", {
|
|
289
290
|
count: run.count, model: run.model, verifier: run.verifier, plan: run.plan, untilStop: run.untilStop,
|
|
290
|
-
roles: roles.map((role) => oneLine(role, 120)), dashboardUrl: run.dashboardUrl,
|
|
291
|
+
budget: run.budget, roles: roles.map((role) => oneLine(role, 120)), dashboardUrl: run.dashboardUrl,
|
|
291
292
|
...(swarm.hordes > 1 ? { swarm: swarm.id, horde: run.horde, hordes: swarm.hordes, slice: run.slice } : {}),
|
|
292
293
|
});
|
|
293
294
|
await recordPhase(run, "discovery", "start");
|
package/src/run/repair.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { recordEvent } from "../ledger/events.js";
|
|
|
7
7
|
import type { EventContext } from "../ledger/events.js";
|
|
8
8
|
import { recordPhase } from "./lifecycle.js";
|
|
9
9
|
import { envNumber } from "../format.js";
|
|
10
|
-
import { resetMemberState } from "../member.js";
|
|
10
|
+
import { resetMemberState, USAGE_KEYS } from "../member.js";
|
|
11
11
|
import type { MemberState } from "../member.js";
|
|
12
12
|
import { hardStopMembers } from "./controls.js";
|
|
13
13
|
import { scheduleCollaboration, stopReason } from "./schedule.js";
|
|
@@ -289,7 +289,7 @@ async function repairMember(phase: RepairPhase, member: MemberState, ms: number,
|
|
|
289
289
|
run.repairTokens = (run.repairTokens ?? 0) + spent();
|
|
290
290
|
member.turns += prior.turns;
|
|
291
291
|
member.toolCalls += prior.toolCalls;
|
|
292
|
-
for (const key of
|
|
292
|
+
for (const key of USAGE_KEYS) member.usage[key] += prior.usage[key];
|
|
293
293
|
member.startedAt = prior.startedAt;
|
|
294
294
|
}
|
|
295
295
|
}
|
package/src/run/setup.ts
CHANGED
|
@@ -93,7 +93,8 @@ export function createRun(
|
|
|
93
93
|
fs.writeFileSync(
|
|
94
94
|
path.join(runDir, "TASK.md"),
|
|
95
95
|
taskMarkdown({
|
|
96
|
-
id, prompt, count, model, thinkingLevel: ctx.thinkingLevel, startedAt, verifier, untilStop: parsed.untilStop,
|
|
96
|
+
id, prompt, count, model, thinkingLevel: ctx.thinkingLevel, startedAt, verifier, untilStop: parsed.untilStop,
|
|
97
|
+
budget: parsed.budget, swarm,
|
|
97
98
|
}),
|
|
98
99
|
"utf-8",
|
|
99
100
|
);
|
|
@@ -123,6 +124,7 @@ export function createRun(
|
|
|
123
124
|
plan,
|
|
124
125
|
verifier,
|
|
125
126
|
untilStop: parsed.untilStop === true ? true : undefined,
|
|
127
|
+
budget: parsed.budget,
|
|
126
128
|
resets: [],
|
|
127
129
|
dashboardUrl,
|
|
128
130
|
};
|
package/src/run/supervise.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { reconcileOwnedIdeas } from "../ledger/ideas.js";
|
|
|
9
9
|
import { cleanStopRequestedFor, clearHeartbeat, readHeartbeat } from "../ledger/run-signals.js";
|
|
10
10
|
import { resetMemberState, spawnMember } from "../member.js";
|
|
11
11
|
import type { MemberState } from "../member.js";
|
|
12
|
+
import { checkBudget } from "./budget.js";
|
|
12
13
|
import { hardStopMembers } from "./controls.js";
|
|
13
14
|
import { recordLifecycle } from "./lifecycle.js";
|
|
14
15
|
import type { HordeRun, HordeState, Swarm } from "./types.js";
|
|
@@ -72,7 +73,7 @@ export async function spawnOne(
|
|
|
72
73
|
approve: ctx.isProjectTrusted(),
|
|
73
74
|
signal: controller.signal,
|
|
74
75
|
role: launch.roles[member.index - 1],
|
|
75
|
-
onChange: () => { onProgress?.(); refreshRun(run); },
|
|
76
|
+
onChange: () => { onProgress?.(); checkBudget(launch); refreshRun(run); },
|
|
76
77
|
onSpawn: (proc) => run.procs.set(member.name, proc),
|
|
77
78
|
onExit: () => {
|
|
78
79
|
run.controller.signal.removeEventListener("abort", onMasterAbort);
|
|
@@ -84,7 +85,7 @@ export async function spawnOne(
|
|
|
84
85
|
await recordLifecycle(run, "member-exit", {
|
|
85
86
|
member: member.name, attempt: member.attempt, status: member.status, endCause: member.endCause,
|
|
86
87
|
exitCode: member.exitCode, turns: member.turns, toolCalls: member.toolCalls,
|
|
87
|
-
tokens: member.usage.input + member.usage.output, partial: member.partial === true,
|
|
88
|
+
tokens: member.usage.input + member.usage.output, cost: member.usage.cost, partial: member.partial === true,
|
|
88
89
|
});
|
|
89
90
|
}
|
|
90
91
|
|
|
@@ -15,6 +15,8 @@ export interface SwarmRecord {
|
|
|
15
15
|
count: number;
|
|
16
16
|
startedAt: number;
|
|
17
17
|
untilStop: boolean;
|
|
18
|
+
/** The swarm's `--budget` in dollars, when one was given. */
|
|
19
|
+
budget?: number | undefined;
|
|
18
20
|
partition: string[];
|
|
19
21
|
runs: Array<{ label: string; runId: string; runDir: string; hordeDir: string; startedAt: number; endedAt?: number | undefined; outcome?: string | undefined }>;
|
|
20
22
|
/** The merge step: when the swarm deliverable helper ran and whether it wrote anything. */
|
|
@@ -30,6 +32,7 @@ export function swarmRecord(swarm: Swarm): SwarmRecord {
|
|
|
30
32
|
count: swarm.count,
|
|
31
33
|
startedAt: swarm.startedAt,
|
|
32
34
|
untilStop: swarm.untilStop === true,
|
|
35
|
+
budget: swarm.budget,
|
|
33
36
|
partition: swarm.partition ?? [],
|
|
34
37
|
runs: swarm.runs.map((run) => ({
|
|
35
38
|
label: `h${run.horde}`,
|
package/src/run/swarm-report.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
-
import { formatDateTime, formatElapsed, formatTokens, oneLine } from "../format.js";
|
|
2
|
+
import { formatDateTime, formatElapsed, formatTokens, formatUsd, oneLine } from "../format.js";
|
|
3
3
|
import { listFindingsForRun } from "../ledger/findings.js";
|
|
4
4
|
import { statusCounts, totalUsage } from "../prompt.js";
|
|
5
5
|
import { hordeLabel } from "./types.js";
|
|
@@ -96,6 +96,7 @@ export function swarmReportMarkdown(swarm: Swarm, now: number = Date.now()): str
|
|
|
96
96
|
`- elapsed: ${formatElapsed(now - swarm.startedAt)}`,
|
|
97
97
|
`- hordes: ${swarm.hordes} x ${swarm.count} members`,
|
|
98
98
|
`- until stop: ${swarm.untilStop ? "yes" : "no"}`,
|
|
99
|
+
...(swarm.budget === undefined ? [] : [`- budget: ${formatUsd(swarm.budget)} across every horde`]),
|
|
99
100
|
`- outcomes: ${rows.map((row) => `${row.label} ${row.outcome}`).join(", ")}`,
|
|
100
101
|
`- merged deliverable: ${swarm.deliverable ? "DELIVERABLE.md" : "not written"}`,
|
|
101
102
|
"",
|
package/src/run/swarm.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { formatDateTime, formatRunId, taskHash } from "../format.js";
|
|
3
|
+
import { formatDateTime, formatRunId, formatUsd, taskHash } from "../format.js";
|
|
4
4
|
import { hordePaths, initHordeDirAt } from "../ledger/files.js";
|
|
5
5
|
import type { SiblingHorde } from "../member.js";
|
|
6
6
|
import { hordeLabel } from "./types.js";
|
|
@@ -99,6 +99,7 @@ export function swarmMarkdown(swarm: Swarm): string {
|
|
|
99
99
|
`Hordes: ${swarm.hordes} x ${swarm.count} members (${hordeLabel(1)} .. ${hordeLabel(swarm.hordes)})`,
|
|
100
100
|
`Model: ${swarm.runs[0]?.model ?? "parent default"}`,
|
|
101
101
|
`Until stop: ${swarm.untilStop ? "yes (every horde continues until stop-clean or stop-hard)" : "no"}`,
|
|
102
|
+
`Budget: ${swarm.budget === undefined ? "none" : `${formatUsd(swarm.budget)} across every horde`}`,
|
|
102
103
|
`Partition: ${swarm.plan ? "plan layers dealt round-robin" : "planner helper"}`,
|
|
103
104
|
`Task-SHA256: ${taskHash(swarm.prompt)}`,
|
|
104
105
|
"",
|
package/src/run/types.ts
CHANGED
|
@@ -83,6 +83,8 @@ export interface HordeRun extends MemberRunInfo {
|
|
|
83
83
|
solvedContest?: { proposal: SolvedSignal; blockers: number[] } | undefined;
|
|
84
84
|
/** True once the parent has warned that a validated solve still has unverified findings. */
|
|
85
85
|
solvedVerificationNotice?: boolean | undefined;
|
|
86
|
+
/** The swarm's `--budget` in dollars, recorded per run for TASK.md and the events. */
|
|
87
|
+
budget?: number | undefined;
|
|
86
88
|
/** Solves banked so far in an `--until-stop` run. */
|
|
87
89
|
solveRounds?: number | undefined;
|
|
88
90
|
/** The latest banked solve of an `--until-stop` run: the answer unless a later proposal is validated. */
|
|
@@ -116,6 +118,10 @@ export interface Swarm {
|
|
|
116
118
|
/** Per-horde workstream, indexed by horde - 1; absent for a swarm of one. */
|
|
117
119
|
partition?: string[] | undefined;
|
|
118
120
|
untilStop?: boolean | undefined;
|
|
121
|
+
/** `--budget`: combined member spend in dollars at which every horde is clean-stopped. */
|
|
122
|
+
budget?: number | undefined;
|
|
123
|
+
/** True once the budget tripped, so the stop is requested exactly once. */
|
|
124
|
+
budgetHit?: boolean | undefined;
|
|
119
125
|
dashboardUrl?: string | undefined;
|
|
120
126
|
/** Generated merged deliverable across the hordes' deliverables. */
|
|
121
127
|
deliverable?: string | undefined;
|
package/src/tool/actions.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { readEvents, recordEvent } from "../ledger/events.js";
|
|
|
23
23
|
import type { EventContext } from "../ledger/events.js";
|
|
24
24
|
|
|
25
25
|
const CLEAN_STOP_NOTICE =
|
|
26
|
-
"A clean stop
|
|
26
|
+
"A clean stop is in effect for this run. Stop starting new work. Mark your claimed ideas done " +
|
|
27
27
|
"or drop them, post a one-line status to BOARD.md, then write your final summary and make no " +
|
|
28
28
|
"further tool calls.";
|
|
29
29
|
|
package/src/tool/params.ts
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Type } from "typebox";
|
|
1
|
+
import { Type, type TUnsafe } from "typebox";
|
|
3
2
|
import { FINDING_CONFIDENCES, FINDING_IMPORTANCE, FINDING_SUPPORT } from "../ledger/findings.js";
|
|
4
3
|
|
|
4
|
+
/**
|
|
5
|
+
* A string enum schema whose static type is the union of its values.
|
|
6
|
+
*
|
|
7
|
+
* pi-ai's StringEnum returns a pi-schema type since pi 1.5, which TypeBox's `Type.Object`
|
|
8
|
+
* cannot infer a static type from, so the schema is built here with TypeBox directly.
|
|
9
|
+
*/
|
|
10
|
+
function StringEnum<T extends readonly string[]>(
|
|
11
|
+
values: T,
|
|
12
|
+
options: { description?: string } = {},
|
|
13
|
+
): TUnsafe<T[number]> {
|
|
14
|
+
return Type.Unsafe<T[number]>({ type: "string", enum: [...values], ...options });
|
|
15
|
+
}
|
|
16
|
+
|
|
5
17
|
export const HordeParams = Type.Object({
|
|
6
18
|
action: StringEnum(
|
|
7
19
|
[
|
package/src/widget.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
-
import { formatElapsed, formatTokens } from "./format.js";
|
|
3
|
+
import { formatElapsed, formatTokens, formatUsd } from "./format.js";
|
|
4
4
|
import type { MemberState } from "./member.js";
|
|
5
5
|
import { memberActivity, statusCounts, totalUsage } from "./prompt.js";
|
|
6
6
|
|
|
@@ -16,12 +16,15 @@ interface WidgetRun {
|
|
|
16
16
|
reported?: boolean | undefined;
|
|
17
17
|
finalized?: boolean | undefined;
|
|
18
18
|
outcome?: string | undefined;
|
|
19
|
+
/** `--budget` in dollars; the header shows spend against it. */
|
|
20
|
+
budget?: number | undefined;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
interface WidgetSwarm {
|
|
22
24
|
id: string;
|
|
23
25
|
runs: WidgetRun[];
|
|
24
26
|
startedAt: number;
|
|
27
|
+
budget?: number | undefined;
|
|
25
28
|
dashboardUrl?: string | undefined;
|
|
26
29
|
/** The swarm step still running after every horde finalized, e.g. ["merge"]. */
|
|
27
30
|
finalizing?: string[] | undefined;
|
|
@@ -56,6 +59,7 @@ export function headerLine(run: WidgetRun, now: number, theme: Theme): string {
|
|
|
56
59
|
if (c.failed) parts.push(theme.fg("error", `${c.failed} failed`));
|
|
57
60
|
if (c.stopped) parts.push(theme.fg("warning", `${c.stopped} stopped`));
|
|
58
61
|
parts.push(formatElapsed(now - run.startedAt));
|
|
62
|
+
if (run.budget !== undefined) parts.push(budgetPart(run.members, run.budget, theme));
|
|
59
63
|
if (run.cleanStopUntil !== undefined) {
|
|
60
64
|
const left = Math.max(0, Math.ceil((run.cleanStopUntil - now) / 1000));
|
|
61
65
|
parts.push(theme.fg("warning", `clean stop in ${left}s`));
|
|
@@ -67,6 +71,11 @@ export function headerLine(run: WidgetRun, now: number, theme: Theme): string {
|
|
|
67
71
|
return `${parts.join(" ")} ${theme.fg("dim", "/agent-horde status | stop-hard | stop-clean")}`;
|
|
68
72
|
}
|
|
69
73
|
|
|
74
|
+
/** `$1.20 of $5.00`: spend over every session so far against the `--budget`. */
|
|
75
|
+
function budgetPart(members: MemberState[], budget: number, theme: Theme): string {
|
|
76
|
+
return theme.fg("dim", `${formatUsd(totalUsage(members).cost)} of ${formatUsd(budget)}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
70
79
|
function memberLine(m: MemberState, now: number, frame: number, theme: Theme): string {
|
|
71
80
|
const elapsed = formatElapsed((m.endedAt ?? now) - m.startedAt);
|
|
72
81
|
const activity = memberActivity(m);
|
|
@@ -118,7 +127,7 @@ export function hordeLine(run: WidgetRun, now: number, frame: number, theme: The
|
|
|
118
127
|
];
|
|
119
128
|
if (c.failed) parts.push(theme.fg("error", `${c.failed} failed`));
|
|
120
129
|
if (c.stopped) parts.push(theme.fg("warning", `${c.stopped} stopped`));
|
|
121
|
-
parts.push(theme.fg("dim", `t${usage.turns} ${formatTokens(usage.input + usage.output)} tok
|
|
130
|
+
parts.push(theme.fg("dim", `t${usage.turns} ${formatTokens(usage.input + usage.output)} tok ${formatUsd(usage.cost)}`));
|
|
122
131
|
parts.push(theme.fg("muted", hordeActivity(run, c)));
|
|
123
132
|
return ` ${glyph} ${`h${run.horde}`.padEnd(3)} ${parts.join(" ")}`;
|
|
124
133
|
}
|
|
@@ -135,6 +144,7 @@ function swarmHeader(swarm: WidgetSwarm, now: number, theme: Theme): string {
|
|
|
135
144
|
if (c.failed) parts.push(theme.fg("error", `${c.failed} failed`));
|
|
136
145
|
if (c.stopped) parts.push(theme.fg("warning", `${c.stopped} stopped`));
|
|
137
146
|
parts.push(formatElapsed(now - swarm.startedAt));
|
|
147
|
+
if (swarm.budget !== undefined) parts.push(budgetPart(members, swarm.budget, theme));
|
|
138
148
|
if (swarm.finalizing && swarm.finalizing.length > 0) {
|
|
139
149
|
parts.push(theme.fg("warning", `synthesizing ${swarm.finalizing.join(", ")}`));
|
|
140
150
|
}
|