Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/subagents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ export interface SubagentManagerOptions {
* this ceiling, so a churn of short-lived children cannot evade it.
*/
maxTotalSpawns?: number;
/**
* Shared budget cap on the cumulative cost (e.g. USD) that all delegated
* children may collectively consume. When the cumulative reported cost reaches
* this cap, further spawns are refused fail-closed (#238). Independent of and
* complementary to maxTotalSpawns. Undefined ⇒ no shared cost budget.
*/
maxTotalCost?: number;
/** Injectable clock for deterministic tests. Defaults to Date.now. */
clock?: () => number;
/**
Expand Down Expand Up @@ -153,14 +160,31 @@ export class SubagentSpawnCapError extends Error {
}
}

/**
* Thrown by the subagent launcher once the shared cost budget (#238) is
* exhausted. The message is a static, deterministic bound notice — it never
* carries secrets, host paths, or untrusted content.
*/
export class SubagentBudgetError extends Error {
readonly reason = "shared_budget" as const;

constructor(message: string) {
super(message);
this.name = "SubagentBudgetError";
}
}

export class SubagentManager {
private readonly entries = new Map<string, Entry>();
private readonly maxConcurrent: number;
private readonly maxTotalSpawns: number;
private readonly maxTotalCost?: number;
private readonly clock: () => number;
private readonly parentWorkspace?: string;
private readonly parentIdentity?: WorkspaceIdentity;
private seq = 0;
// Cumulative cost reported across all delegated children (#238 shared budget).
private cumulativeCost = 0;

constructor(opts: SubagentManagerOptions = {}) {
const max = opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
Expand All @@ -173,6 +197,12 @@ export class SubagentManager {
throw new Error("maxTotalSpawns must be a positive integer");
}
this.maxTotalSpawns = total;
if (opts.maxTotalCost !== undefined) {
if (!Number.isFinite(opts.maxTotalCost) || opts.maxTotalCost <= 0) {
throw new Error("maxTotalCost must be a positive finite number");
}
this.maxTotalCost = opts.maxTotalCost;
}
this.clock = opts.clock ?? (() => Date.now());
if (opts.parentWorkspace !== undefined) {
this.parentWorkspace = opts.parentWorkspace;
Expand Down Expand Up @@ -203,6 +233,11 @@ export class SubagentManager {
`Refusing to spawn a delegated agent: session subagent cap of ${this.maxTotalSpawns} reached`,
);
}
if (this.maxTotalCost !== undefined && this.cumulativeCost >= this.maxTotalCost) {
throw new SubagentBudgetError(
`Refusing to spawn a delegated agent: shared cost budget of ${this.maxTotalCost} exhausted`,
);
}
const mode: WorkspaceMode = opts.mode ?? "mutating";
if (this.parentIdentity) {
const decision = evaluateWorkspaceGuard({
Expand Down Expand Up @@ -267,6 +302,27 @@ export class SubagentManager {
return countByState(this.list());
}

/**
* Report cost consumed by a delegated child, adding it to the cumulative shared
* budget total (#238). Non-finite or negative amounts are ignored so a bad
* report cannot corrupt the budget or open it back up.
*/
addCost(amount: number): void {
if (Number.isFinite(amount) && amount > 0) {
this.cumulativeCost += amount;
}
}

/**
* The shared cost budget state (#238): cumulative reported cost, the cap (null
* when no budget is configured), and the remaining budget (null when uncapped).
*/
costUsage(): { cumulativeCost: number; maxTotalCost: number | null; remaining: number | null } {
const maxTotalCost = this.maxTotalCost ?? null;
const remaining = maxTotalCost === null ? null : Math.max(0, maxTotalCost - this.cumulativeCost);
return { cumulativeCost: this.cumulativeCost, maxTotalCost, remaining };
}

private runningCount(): number {
let n = 0;
for (const entry of this.entries.values()) {
Expand Down
41 changes: 41 additions & 0 deletions tests/integration/subagents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
SubagentManager,
formatSubagentView,
SubagentSpawnCapError,
SubagentBudgetError,
} from "../../src/subagents.js";

// End-to-end lifecycle scenarios that exercise the manager the way a parent
Expand Down Expand Up @@ -206,4 +207,44 @@ describe("Integration: subagent spawn cap dogfood", () => {
expect(view).not.toContain("SECRET");
expect(view).toMatch(/2 total/);
});

it("enforces a shared cost budget across delegated children, failing closed once exhausted", async () => {
// A parent delegates research children that each report a cost. The shared
// budget bounds their aggregate cost; once exhausted, further delegation is
// refused fail-closed even though the spawn-count ceiling is nowhere near.
const mgr = new SubagentManager({ maxConcurrent: 4, maxTotalSpawns: 100, maxTotalCost: 1.0 });

const a = deferred<string>();
const b = deferred<string>();
mgr.spawn(() => a.promise, { label: "researcher-1", mode: "read-only" });
mgr.spawn(() => b.promise, { label: "researcher-2", mode: "read-only" });

// Both children complete and report their cost (0.6 + 0.4 = 1.0).
a.resolve("found X");
b.resolve("found Y");
await flush();
mgr.addCost(0.6);
mgr.addCost(0.4);
expect(mgr.costUsage()).toEqual({ cumulativeCost: 1.0, maxTotalCost: 1.0, remaining: 0 });

// The next delegation is refused fail-closed by the shared budget — even
// though only 2 of 100 spawns are used.
let err: unknown;
try {
mgr.spawn(() => deferred<string>().promise, { label: "researcher-3", mode: "read-only" });
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(SubagentBudgetError);
expect((err as SubagentBudgetError).reason).toBe("shared_budget");
const reason = (err as Error).message;
expect(reason).toContain("shared cost budget of 1 exhausted");
expect(reason).not.toContain("researcher-3");

// The parent and its completed children are unaffected; the refused spawn
// registered nothing.
expect(mgr.list()).toHaveLength(2);
expect(mgr.get("sub-001")?.state).toBe("completed");
expect(mgr.get("sub-002")?.state).toBe("completed");
});
});
79 changes: 79 additions & 0 deletions tests/unit/subagents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
TERMINAL_SUBAGENT_STATES,
SharedWorkspaceLaunchError,
SubagentSpawnCapError,
SubagentBudgetError,
} from "../../src/subagents.js";
import type { SubagentRecord } from "../../src/subagents.js";

Expand Down Expand Up @@ -492,3 +493,81 @@ describe("SubagentManager: spawn cap", () => {
expect(msg).not.toContain("/home/user");
});
});

describe("SubagentManager: shared cost budget", () => {
it("spawns while under budget and refuses once the budget is exhausted", () => {
const mgr = new SubagentManager({ maxTotalCost: 1.0 });
mgr.spawn(() => deferred<string>().promise);
mgr.addCost(0.6);
// Still under budget (0.6 < 1.0): allowed.
mgr.spawn(() => deferred<string>().promise);
mgr.addCost(0.4);
// Now at the cap (1.0 >= 1.0): refused.
expect(() => mgr.spawn(() => deferred<string>().promise)).toThrow(SubagentBudgetError);
});

it("is independent of the spawn-count cap (budget refuses even under the count ceiling)", () => {
const mgr = new SubagentManager({ maxTotalSpawns: 100, maxTotalCost: 0.5 });
mgr.spawn(() => deferred<string>().promise);
mgr.addCost(0.5);
// Well under the spawn cap (1 of 100) but at the cost budget: refused by budget.
let err: unknown;
try {
mgr.spawn(() => deferred<string>().promise);
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(SubagentBudgetError);
expect((err as SubagentBudgetError).reason).toBe("shared_budget");
});

it("imposes no budget when maxTotalCost is not configured", () => {
const mgr = new SubagentManager({ maxTotalSpawns: 100 });
mgr.addCost(1000);
// No budget configured: cost is tracked but never refuses.
expect(() => mgr.spawn(() => deferred<string>().promise)).not.toThrow();
expect(mgr.costUsage()).toEqual({ cumulativeCost: 1000, maxTotalCost: null, remaining: null });
});

it("reports cumulative cost, cap, and remaining via costUsage", () => {
const mgr = new SubagentManager({ maxTotalCost: 2.0 });
expect(mgr.costUsage()).toEqual({ cumulativeCost: 0, maxTotalCost: 2.0, remaining: 2.0 });
mgr.addCost(0.5);
expect(mgr.costUsage()).toEqual({ cumulativeCost: 0.5, maxTotalCost: 2.0, remaining: 1.5 });
mgr.addCost(3.0);
// Remaining floors at 0 once over budget.
expect(mgr.costUsage()).toEqual({ cumulativeCost: 3.5, maxTotalCost: 2.0, remaining: 0 });
});

it("ignores non-finite or negative cost reports", () => {
const mgr = new SubagentManager({ maxTotalCost: 1.0 });
mgr.addCost(Number.NaN);
mgr.addCost(-5);
mgr.addCost(Number.POSITIVE_INFINITY);
expect(mgr.costUsage().cumulativeCost).toBe(0);
});

it("throws on a non-positive or non-finite maxTotalCost", () => {
expect(() => new SubagentManager({ maxTotalCost: 0 })).toThrow(/positive finite number/);
expect(() => new SubagentManager({ maxTotalCost: -1 })).toThrow(/positive finite number/);
expect(() => new SubagentManager({ maxTotalCost: Number.NaN })).toThrow(/positive finite number/);
});

it("refuses with a deterministic, content-free reason (no secret/host/label leak)", () => {
const mgr = new SubagentManager({ maxTotalCost: 0.1 });
mgr.spawn(() => deferred<string>().promise, { label: "SECRET=abc123 /home/user/keys" });
mgr.addCost(0.1);
let err: unknown;
try {
mgr.spawn(() => deferred<string>().promise, { label: "leak this label" });
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(SubagentBudgetError);
const msg = (err as Error).message;
expect(msg).toContain("shared cost budget of 0.1 exhausted");
expect(msg).not.toContain("leak this label");
expect(msg).not.toContain("SECRET");
expect(msg).not.toContain("/home/user");
});
});
Loading