Development

A Tool Call Budget per Run: Counting Writes Before the Start

Antonio Evans8 min read

A split scene: on the left, a stack of five identical wooden tokens labeled with a pencil icon (write), each token being taken one by one by a robotic arm.
Key points

Key points

  1. 1A write-call budget is a hard cap on write and destructive tool calls per run, enforced by your runtime, not the prompt.
  2. 2The model only respects the cap if the remaining count appears in the context on every turn.
  3. 3At zero, the runtime returns a typed error such as BudgetExhausted, and the model can still finish work with read-only tools.
  4. 4A budget limits duplicate damage but does not make a write safe to repeat, so pair it with idempotency keys and deduplication.

Start from the effect classifier you already have

The budget only works if the runtime knows which tools spend. That is the job of a per-effect classifier: one table, reviewed by a person, that labels every tool the agent can see as read, write or destructive. lookup_customer is read. update_crm_field and issue_refund are write. delete_workspace and rotate_production_key are destructive. Classification lives in code and config, not in the prompt, and it should be stated once and enforced everywhere, because letting the agent decide dependency mode at runtime is exactly the mistake sandbox practitioners warn against in sandbox testing guidance for tool calls and side effects.

Two decisions go into that table. First, destructive calls usually get their own counter, often with a budget of one, because a single bad delete is worse than five duplicate updates. Second, some tools are write-only in theory and read-only in practice: a refund that finds the refund already exists changes nothing. Classify by the worst case, then let idempotent implementations reduce the real risk without changing the count.

If classification is new work, derive it from what the tool actually does. Reads return data; writes change stored state or cause an external effect; destructive calls remove state or widen blast radius. When a tool is ambiguous, label it write. A false write label costs budget, a false read label costs money. Runtime enforcement products draw the same line: a Slack agent that summarizes activity does not need to post to channels, and a finance agent flagging discrepancies does not need to approve them, as governance tooling for runtime agent enforcement puts it.

Put the remaining count in run state

The count belongs in the durable run record, next to the run ID and the current step, not in a local variable. Two properties matter. It must survive a crash and restart, and it must be readable at the moment a tool call is dispatched. A run that restarts with a fresh counter has no budget at all.

A minimal record is four fields: the run ID, the write cap, the writes used, and the destructive cap and use. Reserve the call before executing it, then confirm or release. A schema that supports that looks like this:

create table agent_runs (
  run_id        text primary key,
  write_cap     int  not null default 3,
  writes_used   int  not null default 0,
  destroy_cap   int  not null default 1,
  destroys_used int  not null default 0,
  status        text not null default 'running'
);

-- atomic reservation: fails when the cap is already spent
update agent_runs
set writes_used = writes_used + 1
where run_id = $1
  and status = 'running'
  and writes_used < write_cap
returning writes_used, write_cap;

Zero rows returned means the reservation failed, and that is the moment the runtime raises BudgetExhausted. Reserving before the call, rather than incrementing after it, matters: a process that dies mid-call consumed the budget, which is the safe direction to fail. The same persist-before-process discipline is the standard recommendation for webhook handlers that must not run business logic twice, described in Asaas idempotency guidance, where the event is stored under a unique key before any work happens.

Persist-before-execute also gives you a clean audit trail. Every call that spent budget has a row with a timestamp and, if you store it, the idempotency key that went with it.

Show the model the count on every turn

A budget the model cannot see produces two bad behaviors: it plans five writes, hits the wall on the third, and then either stalls or starts guessing at workarounds. Both are avoidable by putting the count in the context the model receives on each turn, not only at the start of the run.

Concretely, add a small block to the system or developer message on every model call, recomputed from run state:

Write budget for this run:
- write calls remaining: 2 of 3
- destructive calls remaining: 1 of 1
- At zero, write calls are rejected with BudgetExhausted.
  Finish with read tools, then report what could not be done.

Three details make this work. The count is recomputed, never cached in a prompt string. It appears after any tool result, so the model sees the effect of its last call. And the zero rule is stated in the same block, so the error the model eventually receives is not a surprise. If you keep the tool schema stable across turns you also keep provider caching useful; the count changes text, not the tool list.

Treat this as context engineering with a hard fallback. The harness controls execution and the model proposes actions, which is the architecture described for production agent runtimes. The visible count improves the odds that the model plans within budget; the runtime reservation guarantees it. Never let the visible count be the only enforcement, because a model can write a tool call that ignores instructions, and a tool call is just text a loop interprets, as this walkthrough of tool calling mechanics explains.

What happens at zero

At zero, the runtime rejects the call before dispatch and returns a typed error. The error shape matters more than the message. Give the model something it can reason about:

{
  "error": "BudgetExhausted",
  "effect": "write",
  "budget": {"cap": 3, "used": 3, "remaining": 0},
  "allowed_next": ["read tools", "finish", "request_budget"],
  "hint": "Use read tools to verify state, then report incomplete writes."
}

Then define the allowed continuations precisely. The model may call read tools, it may finish and report, and it may call a separate request_budget tool that does not itself spend a write. That last tool is an approval step: it pauses the run, stores the reason, and waits for a human or a policy decision. The resumed run gets a new cap recorded in the same row, and the model is told the new number on its next turn.

Event at zeroRuntime actionModel seesWho resolves
Model calls a write toolReject before dispatch, no side effectBudgetExhausted with remaining 0Model, by finishing or reading
Model calls a read toolExecute normallyNormal resultNobody
Model calls request_budgetPause run, store reasonPending approvalHuman or policy
Run restarts mid-runCount survives, reservation standsCurrent count on next turnRuntime
Approval grantedCap raised in run stateNew remaining countModel continues

The alternative, letting the model exceed the cap and logging a warning, defeats the purpose. So does silently returning a generic error, which tends to make models retry the same write, and a retried write is how duplicate refunds happen. One documented incident had a single return refunded four times at $89.99 each because a timeout led to retries of a tool that did not know it had already run, as recounted in this analysis of retry semantics when agents touch the real world.

Pick the cap with numbers, not vibes

A budget that is too small pushes ordinary runs into approval constantly. One that is too large never fires. The useful number comes from your own traces. Count write calls per successful run over a week, take the 95th percentile, and set the cap slightly above it. If successful runs use at most two writes and the occasional legitimate run uses five, a cap of three blocks real work, so either the cap is six or those runs belong behind approval.

A worked example makes the tradeoff concrete. Suppose 1,000 runs a day, and 3 percent of runs go wrong in a way that produces extra writes, each wrong run making four extra writes that cost $2 each in refunds or API charges. That is 30 wrong runs and 120 extra writes a day, or $240. Tighten the cap from eight to four, and suppose that catches half of the extra writes, leaving 60 extra writes and $120 a day. The rest of the saving has to come from idempotency keys, which is why the budget is a bound, not a fix.

Cap settingBlocks legitimate runsBlocks runaway writesExtra approval load
p50 of writesOftenSomeHigh
p95 of writesRarelyMost runaway casesLow
Fixed at 1FrequentlyAlmost allVery high
No capNeverNoneNone, and no protection

Keep the cap per effect class. A support agent might get three writes and one destructive call; a research agent that only reads gets zero writes, which is the cleanest possible enforcement of read-only scope. Different caps per workflow are normal in runtime governance, where policies are set per use case rather than globally, as described in this runtime enforcement walkthrough.

A budget is not idempotency

The most common mistake is treating the cap as a duplicate-charge fix. It is not. If a write call times out and the model retries it within budget, both attempts can land, because the cap counts attempts, not effects. Duplicate protection requires an operation identity that survives retries, which is the standard idempotency key pattern: one key per intended action, generated once, resent on every retry, with the downstream system storing the key and its result, as the Stripe idempotency documentation sets out for payments.

The two mechanisms cover different failures, which is the decision rule worth remembering:

FailureBudget catches itIdempotency key catches it
Agent loops, calling the same write repeatedlyYes, at the capYes, second call is a no-op
Timeout, then a retry of one intended writeNo, retry fits in budgetYes
Prompt injection adds three extra writesPartlyNot necessarily, if each has a fresh key
Model picks the wrong record to updateNoNo
Crash between side effect and journalNoYes, if the key was recorded first

A crash between an effect and the record of that effect is a known gap even in durable execution engines, where a killed worker produced duplicate charges in 3 of 3 trials until an idempotency key was passed downstream, per tests of duplicate tool calls in durable agents. The same split appears in agent idempotency guides: design tools to be inherently idempotent with upserts, add keys to state-modifying tools, and still cap repeated attempts, as this three-approach guide recommends. Budget plus keys plus upserts is the combination that holds.

There is a subtle interaction: an idempotent retry still spends budget if you count attempts. Count writes at reservation, and refund the budget when the tool reports that the effect was already applied by an earlier key. That keeps the count close to real effects while never letting a runaway loop spend more than the cap.

Wiring it into one runtime loop

Putting the pieces together, the dispatch path has five steps and no shortcuts:

  1. Classify the requested tool. Reads skip everything below.
  2. Reserve budget atomically for the effect class. Zero rows means raise BudgetExhausted.
  3. Derive or fetch the operation key for this intended action and pass it to the tool.
  4. Execute, then record the result and, if the tool reports a duplicate effect, refund the reservation.
  5. Append the tool result and the recomputed budget block to the context for the next turn.

Step 5 is what makes the whole thing legible to the model, and it costs a few dozen tokens per turn. If you are calling several model providers and want one place to swap models without rewriting this loop, Quanta Inference gives you text, image and embedding models behind one OpenAI-compatible key (https://quantaintelligence.ai/inference). For teams that would rather not hand-build the dispatch layer, a specialist can work through the design with you at Quanta (https://quantaintelligence.ai/).

Two operational details finish the job. First, log every BudgetExhausted event with the run ID and the tool name; the distribution tells you whether the cap is right. Second, test the zero path in a sandbox before release, because the failure you care about is the model's behavior after rejection, not the rejection itself. Sandbox testing that asserts on record state rather than transcripts is the recommended check, per the sandbox side-effect runbook.

FAQ

FAQ

Does a write-call budget replace idempotency keys?

No. The budget limits how many attempts a run can make; idempotency keys ensure that a repeated attempt does not repeat the effect. Payment systems store each key with its result and return the stored result on a retry, as the Stripe reference describes. Use both, or a timeout followed by a retry will still double-charge.

Should reads count against the budget?

Usually not, because read tools are cheap and safe to repeat, and counting them makes the model conservative about gathering information. The exception is an expensive read against a metered API. Give those a separate cap so the model sees a number for them too.

What should the model do when it hits BudgetExhausted?

It should finish with read tools, verify whatever state it can, and report which writes remain undone. Allow a separate request_budget call that pauses the run for approval. Return a typed error with the remaining count and the allowed next actions rather than a plain failure message, since a bare error invites a retry.

Where should the count live, in the prompt or in code?

Both. Keep the authoritative count in durable run state and enforce it at reservation. Copy it into the model context on every turn for planning. A count that exists only in the prompt is advice, and a count that exists only in code is invisible to the model.

How do I set the cap for a new agent?

Start from traces of successful runs, take the 95th percentile of write calls per run, and set the cap just above it, with a separate destructive cap at one unless you have evidence that is too tight. Watch how often BudgetExhausted fires; frequent fires on successful tasks mean the cap is too low, and no fires over a week means it is probably too high.

Sources

Sources

  1. Voice Agent Sandbox Testing for Tool Calls and Side Effects
  2. AI Agent Governance: Runtime Enforcement with trail
  3. How to implement idempotency in Webhooks
  4. Engineering the Agent Runtime: Context, Concurrency, ...
  5. A Tool Call Is Just Text and a Loop You Own
  6. Side Effects and Sagas: Retry Semantics When Agents ...
  7. Idempotent requests | Stripe API Reference
  8. Restate Durable Agents: Duplicate Tool Calls Tested (2026)
  9. AI agent idempotency design: safe retries and duplicate tool ...
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