logicsrc/packages/agentswarm/src/budget.test.ts
Anthony Ettinger dd7fe0b5c4 feat(agentswarm): M4 — per-agent budget ledger + withBudget guard
BudgetLedger interface + InMemoryBudgetLedger reference impl (reserve/settle/
release with refund of unused holds; mirrors the b1dz budget+ledger concept).
withBudget() wraps any SwarmRunner: reserves up front (throws SwarmError 402 when
out of funds, before running), settles actual cost on success, releases on error.
AgentIdentity DID aligns with @logicsrc/agentstack.

8 new tests; 27/27 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:45:19 +00:00

89 lines
3.1 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { InMemoryBudgetLedger, SwarmError, withBudget } from "./index.js";
import type { SwarmRunner } from "./index.js";
const DID = "did:coinpay:agent:abc";
const ask = { messages: [{ role: "user" as const, content: "hi" }] };
function okRunner(): SwarmRunner {
return {
run: vi.fn(async (input) => ({
threadId: "t",
messages: [...input.messages, { role: "assistant" as const, content: "done" }],
output: "done"
}))
};
}
describe("InMemoryBudgetLedger", () => {
it("credits and reports balance", async () => {
const ledger = new InMemoryBudgetLedger();
expect(await ledger.balance(DID)).toBe(0);
await ledger.credit(DID, 100);
expect(await ledger.balance(DID)).toBe(100);
});
it("reserve holds funds and settle refunds the unused remainder", async () => {
const ledger = new InMemoryBudgetLedger();
await ledger.credit(DID, 100);
const hold = await ledger.reserve(DID, 40);
expect(await ledger.balance(DID)).toBe(60);
await ledger.settle(hold, 10);
expect(await ledger.balance(DID)).toBe(90); // 60 + 30 refunded
});
it("reserve throws SwarmError 402 when short", async () => {
const ledger = new InMemoryBudgetLedger();
await ledger.credit(DID, 5);
await expect(ledger.reserve(DID, 10)).rejects.toMatchObject({ status: 402 });
});
it("release returns the full hold", async () => {
const ledger = new InMemoryBudgetLedger();
await ledger.credit(DID, 50);
const hold = await ledger.reserve(DID, 50);
expect(await ledger.balance(DID)).toBe(0);
await ledger.release(hold);
expect(await ledger.balance(DID)).toBe(50);
});
});
describe("withBudget", () => {
it("charges the actual cost on success", async () => {
const ledger = new InMemoryBudgetLedger();
await ledger.credit(DID, 100);
const runner = withBudget({ runner: okRunner(), ledger, agentDid: DID, maxCost: 30, costOf: () => 12 });
const res = await runner.run(ask);
expect(res.output).toBe("done");
expect(await ledger.balance(DID)).toBe(88); // 100 - 12
});
it("rejects with 402 and never runs when out of budget", async () => {
const ledger = new InMemoryBudgetLedger();
await ledger.credit(DID, 5);
const inner = okRunner();
const runner = withBudget({ runner: inner, ledger, agentDid: DID, maxCost: 30 });
await expect(runner.run(ask)).rejects.toMatchObject({ status: 402 });
expect(inner.run).not.toHaveBeenCalled();
});
it("releases the hold (no charge) when the run throws", async () => {
const ledger = new InMemoryBudgetLedger();
await ledger.credit(DID, 100);
const failing: SwarmRunner = {
run: vi.fn(async () => {
throw new Error("boom");
})
};
const runner = withBudget({ runner: failing, ledger, agentDid: DID, maxCost: 30 });
await expect(runner.run(ask)).rejects.toThrow("boom");
expect(await ledger.balance(DID)).toBe(100); // fully restored
});
it("re-exports SwarmError for status mapping", () => {
expect(new SwarmError("x", 402).status).toBe(402);
});
});