mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 14:57:28 +00:00
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>
This commit is contained in:
parent
925894359f
commit
dd7fe0b5c4
3 changed files with 202 additions and 0 deletions
89
packages/agentswarm/src/budget.test.ts
Normal file
89
packages/agentswarm/src/budget.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue