Development

A Spend Cap Across Sub-Agents: When One Run Fans Out to Five

Antonio Evans10 min read

A parent node at the top with five child nodes below, each child connected by an arrow.
Key points

Key points

  1. 1Hold the shared budget counter in a durable store outside every agent process, so any child on any machine reads the same number.
  2. 2Every model call must be preceded by an atomic, idempotent reservation, so the ceiling is enforced before money is spent rather than after.
  3. 3Settle actual cost on completion and return the unused remainder, so reserved-but-unspent money becomes available to siblings.
  4. 4Split the parent budget as a fixed share per child plus a shared pool, and award the leftover by a written tie-break rule you can defend in an audit.

Why a per-process counter silently breaks the cap

A parent run that fans out to five children looks like one budget and behaves like six. If each child holds a local variable, spent = 0, and checks it before calls, each child believes it has the full remaining budget. Five children each convinced they may spend the parent's residue will spend it five times over. The failure is not a rounding error; it is a structural one, and it gets worse as fan-out widens.

This is the same class of problem that payment systems solved years ago with idempotency keys: the client generates a unique key before the first attempt, the server stores the key with the result, and a retry returns the stored outcome instead of repeating the side effect the Zuplo guide to idempotency keys. The important lesson for budgets is the ordering. The key is created before the first request is sent, not during a retry, so every attempt of the same logical operation carries the same identity Ali Aftab on production idempotency design. A reservation works the same way: identity first, spend second.

Two further facts shape the design. Concurrent requests with the same key need database-level or distributed locks so only one executes while others wait or read the stored result the Zuplo guide. And distributed state is the hard part at scale: when requests land on different servers, each one needs access to the same limit information, which means a centralized store or a distributed counting mechanism with consistency trade-offs Gravitee on rate limiting at scale. A spend cap across sub-agents is a rate limit with a dollar unit attached, and it inherits those constraints.

There is a second reason to care that goes beyond the invoice. Practitioners building governed agents describe spend controls as part of the gateway that turns policy into enforceable decisions across every model call, tool call, and agent hop LangChain's governed-agents framework. If the cap lives in an agent process, it is not a control plane. It is a wish.

The worked example: one parent, five children, a $2.00 run

Take a parent run with a hard ceiling of $2.00 for one session. It spawns five children: a planner, a retrieval worker, two extraction workers, and a writer. Suppose the parent has already spent $0.20 on its own planning call. The remaining pool is $1.80.

A naive split of $1.80 across five children gives $0.36 each. That is fair in arithmetic and unfair in workload: the writer typically needs more output tokens than a retrieval worker. A better structure is a two-part allocation, a guaranteed share plus a claim on a shared pool.

ChildRoleGuaranteed shareClaim on shared poolNotes
C1Planner$0.10Yes, last in lineSmall context, few calls
C2Retrieval$0.20Yes, third in lineMany cheap calls, short outputs
C3Extraction A$0.30Yes, second in lineLong inputs, structured output
C4Extraction B$0.30Yes, second in lineSame shape as C3
C5Writer$0.40Yes, first in lineLongest output, most retries
PoolUnassigned$0.50n/aReleased only by the rule below

Guaranteed shares total $1.30, leaving $0.50 in the pool. Total reserved capacity is $1.80, exactly the remaining budget. The pool is where fairness arguments actually happen, so the rule for spending it matters more than the initial split.

A workable decision rule: the pool is released to a child only when that child has consumed its guaranteed share, has a concrete pending call, and no sibling in a higher priority class is waiting. Priority classes are written in configuration, not chosen by the model. The writer outranks extraction, extraction outranks retrieval, and the planner is last. Ties within a class are broken by earliest request timestamp, then by child ID, so the outcome is deterministic and reproducible.

The reason to fix priority in config rather than let the agent decide is that the model should never choose its own gate. Finance control design makes the same point: limits should come from the delegation-of-authority policy and be written as fixed routing rules so the agent never decides its own gate Keboola's human-in-the-loop glossary. Fair division research reaches a parallel conclusion from a different direction: when agents have budget constraints, allocations must satisfy those constraints and naive envy-freeness often cannot be guaranteed, so you need an explicit, well-defined relaxation rather than an implicit hope Barman et al. on fair allocation under budget constraints.

A note on expectation setting: budget awareness changes behavior, not just accounting. Agents given more tool-call budget without any signal of how much remains tend to perform shallow work and saturate early; a lightweight budget tracker that continuously reports remaining resources produces better cost-performance curves Budget-Aware Tool-Use Enables Effective Agent Scaling. So expose the remaining pool to the child as context. It will plan differently.

Where to hold the counter

Hold it in a shared durable store that every child reaches over the network, and treat that store as the only authority. Three placements work in practice; the choice is about failure modes, not elegance.

PlacementHolds the cap?Survives a child crash?Extra infraBest for
In-process variable in the parentNo, children on other workers divergeYes, triviallyNoneSingle-process, single-child runs only
Environment variable or config read at startNo, it is a snapshot, not a counterNoNoneNever for a cap
Durable table in a relational database with row locksYes, with transactionsYesA databaseTeams already running Postgres
Centralized key-value store with atomic decrementYes, if the decrement is atomicYesA cache clusterHigh call volume, many runs
Gateway in front of every model callYes, if it is the only path to the providerYesA gatewayOrganizations with several agent teams

The last row is the strongest structural option, because it makes bypass impossible rather than merely discouraged. A gateway is described as the runtime control plane that turns policy into enforceable decisions across model calls, tool calls, and agent hops LangChain's governed-agents framework. If a child can call the provider directly with its own key, no counter you build will hold.

For most teams, start with the database. It gives you atomic transactions, row-level locks, and an audit trail in one system you already operate. The reserve table is small.

CREATE TABLE run_budget (
  run_id        TEXT PRIMARY KEY,
  ceiling_usd   NUMERIC(12,6) NOT NULL,
  reserved_usd  NUMERIC(12,6) NOT NULL DEFAULT 0,
  spent_usd     NUMERIC(12,6) NOT NULL DEFAULT 0,
  CONSTRAINT no_overspend CHECK (reserved_usd + spent_usd <= ceiling_usd)
);

CREATE TABLE reservation (
  reservation_id TEXT PRIMARY KEY,   -- idempotency key, generated before the call
  run_id         TEXT NOT NULL REFERENCES run_budget(run_id),
  child_id       TEXT NOT NULL,
  amount_usd     NUMERIC(12,6) NOT NULL,
  state          TEXT NOT NULL,      -- 'held' | 'settled' | 'released'
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

The check constraint is the last line of defense. It cannot stop a logical error in application code, but it makes an over-reservation physically impossible at the storage layer, which is exactly where you want the guarantee to live.

A note on the semantics of a ceiling. AWS documents its own throttles as applied on a best-effort basis and describes them as targets rather than guaranteed request ceilings the AWS API Gateway throttling docs. A spend cap should not carry that caveat. If your provider bills asynchronously, or if a call can be charged after your reservation is settled, then your reservation amount must be an upper bound, not an estimate of the mean.

Reserve before the call, settle after it

The protocol has three steps and no exceptions. Reserve, call, settle.

Step 1, reserve. The child looks up its planned next call, computes an upper-bound cost from prompt tokens, max output tokens, and the provider's published rate, then writes a reservation. The write must be atomic: check available budget and insert the hold in the same transaction, or use a conditional update that fails when the check would break. The reservation ID is generated before the call and reused on every retry.

Step 2, call. If the reservation fails because the budget is exhausted, the child does not call the provider. It stops, records budget_exhausted, and returns whatever partial result it has. This is the whole point: the ceiling holds because spending requires a successful reservation.

Step 3, settle. On completion, the child writes the actual cost from the provider's response and converts the difference back to available budget. On a crash, a sweeper settles stale holds as released after a timeout, or as spent at the reserved amount if the provider confirms the call happened. Reservation records need an expiry: guidance on idempotency keys suggests keeping them long enough to cover typical retry windows and no longer the Zuplo guide.

def reserve(conn, run_id, child_id, amount, reservation_id):
    with conn.transaction():
        row = conn.execute(
            "SELECT ceiling_usd, reserved_usd, spent_usd "
            "FROM run_budget WHERE run_id = %s FOR UPDATE", (run_id,)
        ).fetchone()
        available = row.ceiling_usd - row.reserved_usd - row.spent_usd
        if amount > available:
            return False
        conn.execute(
            "INSERT INTO reservation (reservation_id, run_id, child_id, amount_usd, state) "
            "VALUES (%s, %s, %s, %s, 'held') ON CONFLICT DO NOTHING",
            (reservation_id, run_id, child_id, amount),
        )
        conn.execute(
            "UPDATE run_budget SET reserved_usd = reserved_usd + %s WHERE run_id = %s",
            (amount, run_id),
        )
    return True

def settle(conn, run_id, reservation_id, actual):
    with conn.transaction():
        held = conn.execute(
            "SELECT amount_usd FROM reservation "
            "WHERE reservation_id = %s AND state = 'held' FOR UPDATE",
            (reservation_id,),
        ).fetchone()
        if held is None:
            return  # already settled or released: safe on retry
        conn.execute(
            "UPDATE reservation SET state = 'settled' WHERE reservation_id = %s",
            (reservation_id,),
        )
        conn.execute(
            "UPDATE run_budget SET reserved_usd = reserved_usd - %s, "
            "spent_usd = spent_usd + %s WHERE run_id = %s",
            (held.amount_usd, actual, run_id),
        )

Two details in that code carry most of the safety. FOR UPDATE makes the read and the write one atomic step, which is the locking approach the idempotency literature prescribes for concurrent duplicate requests the Zuplo guide. And settle returns early when the reservation is not in held state, so a retried settle is a no-op rather than a double charge.

Instrument the path or you will not know which child ate the budget. Agent observability means capturing traces of execution flows, tool calls, and the parameters behind each action, so you can answer not just what happened but why Rubrik on agent observability. Microsoft's guidance adds evaluations and governance to the traditional metrics, logs, and traces trio Azure's agent observability practices. Practically: tag every reservation with run_id and child_id, and chart reserved versus settled by child. A child whose reserved amount consistently overshoots settled cost is wasting pool capacity that a sibling needed.

The fairness rule for the remainder, with numbers

Run the worked example to the end and the rule becomes concrete. Suppose the parent spent $0.20, and the children consume as follows before any pool release: C1 used $0.06 of its $0.10, C2 used $0.19 of its $0.20, C3 used $0.30 of $0.30, C4 used $0.22 of $0.30, and C5 used $0.40 of $0.40. Settled child spend is $1.17, so the pool plus unused guarantees is $1.80 minus $1.17, or $0.63 available.

Now three children want more. C2 asks for $0.05, C4 asks for $0.10, and C5 asks for $0.30. Under priority ordering, C5 is first in line and takes $0.30, leaving $0.33. C4 is second and takes its $0.10, leaving $0.23. C2 is third and gets the $0.05 it asked for. Total new reservations are $0.45, the pool retains $0.18, and the absolute ceiling is untouched because every one of those three reservations passed the same check constraint.

Without a rule, the outcome is whoever asks first or whoever retries hardest. That is not fairness, it is timing luck, and it produces the noisy-neighbor problem that multi-tenant systems solve with layered limits and fair queuing Gravitee on rate limiting at scale. The priority class is the equivalent of a tenant tier.

Two guardrails make the rule survivable in production. First, cap any single pool release at a fraction of the pool, so no child can drain it in one call. Second, require human approval when a child asks to exceed its guaranteed share by more than a set threshold. Human-in-the-loop approval is the standard mechanism for pausing before a consequential action and resuming only on approval, and frameworks exist that implement the pause and resume natively Permit's human-in-the-loop guide. A pool release above the threshold is exactly that kind of action.

A warning about the alternative you may be tempted to build. Provider-side session budgets exist and are useful: LiteLLM tracks spend per session keyed by a trace ID and returns a 429 when a per-session dollar cap is exceeded the LiteLLM iteration and budget docs. Use it as a backstop. But a provider session counter is checked before a call and accumulated after it, which means it is a running total rather than a reservation, and concurrent children can each pass the check with the same headroom. The reserve table closes that window because the hold is committed before the call leaves your network.

Checklist before you ship

CheckWhy it mattersFails loudly if skipped
Shared store is the only source of available budgetLocal state diverges across workersChildren on other machines spend freely
Reservation is atomic with the availability checkRead-then-write races admit over-spendTwo children pass the same check
Reservation ID generated before the call and reused on retryDuplicate holds or missing settlementRetries double-count or vanish
Settle is idempotent and returns early on non-held stateRetried responses corrupt totalsSpent amount balloons on network errors
Upper-bound estimate, not average, for reservation amountLate billing exceeds the holdSettled cost exceeds reserved, cap drifts
Stale holds expire on a timerCrashed children freeze budget foreverPool starves with no spending
Priority class in config, not chosen by the modelModel-selected gates get gamedThe writer outranks extraction on a whim
Every model call passes through the gateA bypass path voids the controlOne direct key defeats the whole run
Reserved versus settled charted per childOvershoot hides in aggregatesYou learn about waste at invoice time

Pricing accuracy deserves its own sentence. A cap is only as good as the rates behind it, and rates change. Store the rate you used with each reservation so a later review can tell whether an over-run was a logic bug or a stale price LangChain's governed-agents framework.

If you would rather not operate the reserve table, the gateway, and the settlement sweeper yourself, that is the layer Quanta Inference already provides: text, image, video, audio and embedding models through one API key, OpenAI-compatible, prepaid and priced in US dollars, at https://quantaintelligence.ai/inference. Prepaid balance plus one key per run gives you a hard boundary that no child can talk its way past, and you can still keep the reserve table for per-child fairness accounting on top of it.

FAQ

FAQ

Where exactly should the shared counter live?

In a durable store reachable by every process that can make a model call, and nowhere else. A relational database with row locks is the simplest correct answer, because you get the atomic check-and-hold in one transaction and an audit trail for free. The counter must never be a variable in the parent, an environment variable, or anything a child can cache and drift from.

What stops a crashed child from freezing budget forever?

A sweeper that expires holds older than a set timeout, releasing them to the pool or settling them at the reserved amount when the provider confirms the call completed. Reservation records should persist long enough to cover the retry window and no longer the Zuplo guide. Without expiry you get a pool that is technically full and practically unusable.

Should I reserve the estimated cost or the maximum possible cost?

Reserve an upper bound computed from prompt tokens, maximum output tokens, and the published rate. Reserving the expected cost means a verbose response settles above the hold, and then either your ceiling leaks or your settlement fails. The cost of an upper bound is a temporarily smaller pool; the cost of a mean is a cap that does not hold.

How do I stop one child from starving its siblings?

Give every child a guaranteed share it can spend without asking, and put contention only on a shared pool released by a written priority rule with a deterministic tie-break. Fix the priority in configuration so the model never decides its own gate, the same principle behind approval routing rules Keboola's human-in-the-loop glossary. Cap single releases at a fraction of the pool as a second guardrail.

Does a provider-side session budget make my reserve table unnecessary?

No, it makes a useful backstop. Provider session caps are checked before a call and accumulated after it, so concurrent children can each pass the same check with the same headroom the LiteLLM iteration and budget docs. A committed reservation closes that window because the hold exists before the request leaves your network. Run both: the provider cap catches your bugs, your table allocates the remainder fairly.

Sources

Sources

  1. Implementing Idempotency Keys in REST APIs - Zuplo
  2. Idempotency Keys in Node.js: A Production-Safe Design ... - Medium
  3. API Rate Limiting at Scale: Patterns, Failures, and Control ...
  4. Building Governed Agents: A Framework for Cost, Control ...
  5. What Is Human-in-the-Loop AI in Finance? | Keboola
  6. Finding Fair Allocations under Budget Constraints
  7. Budget-Aware Tool-Use Enables Effective Agent Scaling
  8. Throttle requests to your REST APIs for better throughput in ...
  9. Agent Observability: How to Monitor AI Agents - Rubrik
  10. Top 5 agent observability best practices for reliable AI
  11. Human in the Loop AI Agents: Approval Workflows That Work
  12. Agent Iteration Budgets
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles