agent-horde 0.2.0 → 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.
@@ -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}`,
@@ -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
@@ -38,6 +38,8 @@ export interface HordeRun extends MemberRunInfo {
38
38
  repairTokens?: number;
39
39
  /** Members whose queued repair work was skipped because the budget could not fund an attempt. */
40
40
  repairSkipped?: string[];
41
+ /** Members whose remaining repair threads their own completed attempt could not move. */
42
+ repairStalled?: string[];
41
43
  /** True when a user command or session shutdown ended the run. */
42
44
  userStop?: boolean | undefined;
43
45
  /** Human-readable stop reason for the report, e.g. "stopped by user". */
@@ -81,6 +83,8 @@ export interface HordeRun extends MemberRunInfo {
81
83
  solvedContest?: { proposal: SolvedSignal; blockers: number[] } | undefined;
82
84
  /** True once the parent has warned that a validated solve still has unverified findings. */
83
85
  solvedVerificationNotice?: boolean | undefined;
86
+ /** The swarm's `--budget` in dollars, recorded per run for TASK.md and the events. */
87
+ budget?: number | undefined;
84
88
  /** Solves banked so far in an `--until-stop` run. */
85
89
  solveRounds?: number | undefined;
86
90
  /** The latest banked solve of an `--until-stop` run: the answer unless a later proposal is validated. */
@@ -114,6 +118,10 @@ export interface Swarm {
114
118
  /** Per-horde workstream, indexed by horde - 1; absent for a swarm of one. */
115
119
  partition?: string[] | undefined;
116
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;
117
125
  dashboardUrl?: string | undefined;
118
126
  /** Generated merged deliverable across the hordes' deliverables. */
119
127
  deliverable?: string | undefined;
@@ -7,7 +7,7 @@ import type { FactEntry } from "../ledger/facts.js";
7
7
  import * as path from "node:path";
8
8
  import { CLUSTER_THRESHOLD } from "../ledger/clusters.js";
9
9
  import { collaborationState } from "../ledger/collaboration.js";
10
- import { addFinding, amendFinding, answerCitations, findingReviewState, findingRevision, findingSummaries, listFindingsForRun, pendingReviewsForMember, staleAnswerCitations, verifyFinding } from "../ledger/findings.js";
10
+ import { addFinding, amendFinding, answerCitations, findingReviewState, findingRevision, findingSummaries, listFindingsForRun, pendingReviewsForMember, staleAnswerFor, verifyFinding } from "../ledger/findings.js";
11
11
  import { textOverlap } from "../ledger/text-match.js";
12
12
  import { addIdea, claimIdea, dropIdea, finishIdea, listIdeas, ruleOutIdea } from "../ledger/ideas.js";
13
13
  import type { Idea } from "../ledger/ideas.js";
@@ -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 was requested by the user. Stop starting new work. Mark your claimed ideas done " +
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
 
@@ -466,13 +466,13 @@ function resourcesAction({ hordeDir, siblings }: ActionContext): ActionResult {
466
466
  }
467
467
 
468
468
  /**
469
- * An answer that cites a superseded finding, or one amended since the proposal pinned it, ships a
470
- * stale reading as the run's result. The proposer cites the current text instead; a validator is
471
- * refused until the proposer re-proposes.
469
+ * An answer that cites a superseded finding, or a finding amended or an artifact republished since
470
+ * the proposal pinned it, ships a stale reading as the run's result. The proposer cites the
471
+ * current text instead; a validator is refused until the proposer re-proposes.
472
472
  */
473
473
  function staleAnswer(ctx: ActionContext, text: string, cites?: Record<string, string>): string | undefined {
474
474
  const runId = requireRun(ctx);
475
- const stale = staleAnswerCitations(listFindingsForRun(ctx.hordeDir, runId), text, cites);
475
+ const stale = staleAnswerFor(ctx.hordeDir, runId, text, cites);
476
476
  if (!stale.length) return undefined;
477
477
  return `cites ${stale.join(", ")}`;
478
478
  }
@@ -489,7 +489,7 @@ async function solvedAction(ctx: ActionContext): Promise<ActionResult> {
489
489
  runId,
490
490
  ctx.member,
491
491
  ctx.params.text,
492
- answerCitations(listFindingsForRun(ctx.hordeDir, runId), text),
492
+ answerCitations(listFindingsForRun(ctx.hordeDir, runId), text, collaborationState(ctx.hordeDir, runId).artifacts),
493
493
  );
494
494
  return {
495
495
  text: alreadySolved
@@ -107,11 +107,12 @@ function responseSubject(c: CollaborationCommand, m: PeerMessage): string {
107
107
  }
108
108
 
109
109
  /**
110
- * A blocking request closes on the recipient's response plus the sender's own supporting check of
111
- * the current revision. Before any response the sender may still withdraw its own challenge on an
112
- * F# or A#: a challenger that verified the claim itself, or watched others verify it, records a
113
- * supporting review of the current revision and cites evidence on it. Limits that review
114
- * discloses stay acceptance debt on the review record; the withdrawal clears only the dispute.
110
+ * A blocking request closes on the recipient's response plus a supporting check of the current
111
+ * revision: the sender's own review on a challenge, or the recipient's review when the sender owns
112
+ * the target and so may not review it. Before any response the sender may still withdraw its own
113
+ * challenge on an F# or A#: a challenger that verified the claim itself, or watched others verify
114
+ * it, records a supporting review of the current revision and cites evidence on it. Limits that
115
+ * review discloses stay acceptance debt on the review record; the withdrawal clears only the dispute.
115
116
  */
116
117
  function validateResolution(c: CollaborationCommand, m: PeerMessage, refs: string[]): void {
117
118
  if (m.sender === m.to) throw new Error("horde: a blocking request needs an independent resolver");
@@ -121,13 +122,19 @@ function validateResolution(c: CollaborationCommand, m: PeerMessage, refs: strin
121
122
  if (refs.length === 0 || (withdrawal && !reviewable) || (!withdrawal && !["responded", "deferred"].includes(m.state))) {
122
123
  throw new Error("horde: blocking resolution needs a response and evidence");
123
124
  }
124
- const revision = targetInfo(c.ctx, c.state, target).revision;
125
+ const { owner, revision } = targetInfo(c.ctx, c.state, target);
125
126
  if ((c.params.revision ?? m.responseRevision ?? revision) !== revision) throw new Error(`horde: response is stale; explicitly review and resolve ${target}@${revision}, or ask the owner to respond again`);
126
127
  if (!refs.some((id) => c.state.evidence.some((e) => e.id === id && e.target === target && e.revision === revision))) {
127
128
  const detail = staleEvidence(c.state, refs, target, revision) ?? `none of ${refs.join(", ")} targets ${target}@${revision.slice(0, 12)}`;
128
129
  throw new Error(`horde: resolution evidence must address the current response target revision: ${detail}; attach evidence to that revision first`);
129
130
  }
130
131
  if (!reviewable) return;
132
+ if (owner === c.ctx.actor) requireRecipientReview(c, m, target, revision, withdrawal);
133
+ else requireOwnReview(c, target, revision, withdrawal);
134
+ }
135
+
136
+ /** A challenger settles its own dispute: its latest review of the current revision must support. */
137
+ function requireOwnReview(c: CollaborationCommand, target: string, revision: string, withdrawal: boolean): void {
131
138
  const review = c.state.reviews.findLast((r) => r.target === target && r.revision === revision && r.actor === c.ctx.actor);
132
139
  if (!review || review.verdict !== "supports") {
133
140
  throw new Error(withdrawal
@@ -137,6 +144,24 @@ function validateResolution(c: CollaborationCommand, m: PeerMessage, refs: strin
137
144
  if (!withdrawal && target.startsWith("A") && review.unchecked) throw new Error("horde: resolve requires your supporting review of the current revision; finding limitations remain acceptance debt");
138
145
  }
139
146
 
147
+ /**
148
+ * The owner of the target cannot review it, so a request it sent about its own work (a re-review
149
+ * after a correction) resolves on the recipient's supporting review of the current revision. The
150
+ * refusal names what is missing, since telling the owner to review its own artifact leads nowhere.
151
+ */
152
+ function requireRecipientReview(c: CollaborationCommand, m: PeerMessage, target: string, revision: string, withdrawal: boolean): void {
153
+ const review = c.state.reviews.findLast((r) => r.target === target && r.revision === revision && r.actor === m.to);
154
+ const short = `${target}@${revision.slice(0, 12)}`;
155
+ const prefix = `horde: ${target} is yours, so ${m.id} resolves on ${m.to}'s review`;
156
+ if (!review || review.verdict !== "supports") {
157
+ const missing = review ? `${review.id} is ${review.verdict}, not supports` : `no supporting review of ${short} by ${m.to} yet`;
158
+ throw new Error(`${prefix}: ${missing}; ask ${m.to} to review the current revision`);
159
+ }
160
+ if (!withdrawal && target.startsWith("A") && review.unchecked) {
161
+ throw new Error(`${prefix}: ${review.id} left unchecked: ${review.unchecked}; ask ${m.to} for a full review of ${short}`);
162
+ }
163
+ }
164
+
140
165
  function attachEvidence(c: CollaborationCommand): EventDraft {
141
166
  const { ctx, state, params: p, seq } = c;
142
167
  const target = requireParam(p.target, "target", p.action);
@@ -1,7 +1,19 @@
1
- import { StringEnum } from "@earendil-works/pi-ai";
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 $${usage.cost.toFixed(2)}`));
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
  }