pub struct BudgetPool { /* private fields */ }Expand description
A token allowance several runs draw on at once.
§Shared mutable state, deliberately
Everything else a run is configured with is an immutable value: a
RunSpec derived from another spec leaves the original alone, and two runs
minted from one spec cannot influence each other. A pool is the opposite by
construction — twenty concurrent runs drawing on one figure have to see
each other’s spending, or it is not one figure. So this is the sanctioned
exception: the handle is cheap to clone and every clone is the same pool,
because the allowance lives behind an Arc and moves under an atomic.
Clone here means “another handle”, never “another allowance”, and
PartialEq says so — two handles compare equal when they meter the same
counter against the same limit, not when they happen to name the same
number. A capped view shares the counter but carries its own tighter limit,
so it is deliberately not equal to its parent.
§There is no reservation, and none is possible
A run’s token usage is only known once a round has streamed in full, so
nothing can be set aside for a run in advance and reconciled later without
guessing at the size of the guess. This does not guess. The pool is the
counter mentra keeps: attaching one to a run hands mentra the same
AtomicU64 every other run on this pool reports into
(RunOptions::token_usage, which mentra
documents as the way to alias one run’s accounting to another’s), together
with the pool’s limit as that run’s token_budget. Every round of every
drawing run adds its input+output to the one total, and every round boundary
in every drawing run compares that total to the limit. So remaining is
not an estimate that a settlement later corrects — it is the number the
turns are being stopped against, read live.
A run that spends nothing therefore costs the pool nothing. Nothing is held back on its behalf and nothing has to be returned.
§How much it can overshoot
A pool bounds what a run starts, not what it finishes. Usage is known only
at a round boundary, so the round that crosses the line always completes —
that is the softness Bounds::token_budget
already documents — and
with N runs in flight, N of them can be mid-round when the line is crossed.
State it as: the pool lands at up to limit plus one round from each run
that was running when the limit was reached. For a sequential caller that
is one round; for a twenty-way fan-out it is twenty. If that tail matters,
cap each run with RunSpec::with_token_budget as well, or fan out less
widely — but do not read limit as a ceiling.
§What it sees, and what it does not
Delegated work is inside the pool, whichever door it came through. mentra’s
task intrinsic and basis’s own spawn (ADR-0016, the door the model
actually holds) both drive the subagent on the parent run’s
RunOptions::child, which carries the
same accounting handle — this pool’s counter — and the same bound, so a
fan-out whose runs delegate draws on one figure at every depth rather than
spending beside it. What the two doors do NOT share is the tally: task
relays its child’s usage reports onto the parent’s stream, so RunUsage
agrees with what stopped the turn — but the relay is pub(crate) in
mentra, spawn cannot reach it, and a spawn-delegating run’s RunUsage
under-reports what the pool honestly charged. The bound is airtight; the
receipt is not. Named as an open upstream candidate in the REDESIGN ledger.
Before mentra 0436bae none of the bounding held either: task ran its
child on fresh options, and a delegating fan-out spent more than this pool
would ever admit to.
The edge that survives is a refusal rather than an overrun. A delegation issued once the pool is already crossed inherits an allowance with nothing in it, does zero rounds, and fails the tool call visibly instead of returning an empty success — the delegating side of the same round-boundary softness described above.
What genuinely stays outside is RunUsage’s caveat and not a structural
one: this counts what providers report. One that reports nothing spends
nothing as far as the pool is concerned.
§Running out
A turn that draws on a pool with nothing left is refused —
RunError::BudgetExhausted — before the
prompt is sent, before the header is emitted, and before anything is
committed to the conversation. It is a decision, not a failure of the work,
and it is stated once at the point where money would be spent, so a run
minted while the pool was full and driven after it drained still gets it.
The alternative was to let it through with a zero budget, and it is worth
recording why not. mentra checks reported >= budget, so Some(0) is
already crossed before the first round: the run ends gracefully having done
nothing, and because it owes its caller a final assistant message that never
arrived, it surfaces as EmptyAssistantResponse — a provider-shaped error
for an accounting decision, with the user’s prompt left committed to the
transcript. The report does name
Bound::TokenBudget now that mentra records
which bound ended a run, so the error is at least not mistaken for a broken
provider; the wasted turn and the stranded prompt are what refusing still
avoids. basis/tests/budget.rs pins that upstream behavior so the
reasoning stays checkable.
A run already underway when the pool drains is stopped rather than
refused: it ends at its next round boundary, keeps what it committed, and
reports Bound::TokenBudget. Two answers on
purpose — the refusal says nothing was spent, the bound says something was.
A caller that would rather not mint at all asks first, since both readings are honest live numbers:
let pool = BudgetPool::new(500_000);
while pool.remaining() > 20_000 {
// mint another run
}§Using one
use basis::{BudgetPool, CollectingSink, Workspace};
let workspace = Workspace::open("/repo").await?;
let pool = BudgetPool::new(500_000);
// Two runs, one allowance. Neither knows about the other; both stop when
// the pair of them has spent 500k.
let mut first = workspace.prepare(pool.spec("review the tests"))?;
let mut second = workspace.prepare(pool.spec("review the docs"))?;
let (a, b) = tokio::join!(
first.execute(CollectingSink::default()),
second.execute(CollectingSink::default()),
);
println!("the job cost {} of {}", pool.spent(), pool.limit());Implementations§
Source§impl BudgetPool
impl BudgetPool
Sourcepub fn new(limit: u64) -> Self
pub fn new(limit: u64) -> Self
A pool with limit tokens in it, input plus output.
The same figure RunUsage::total_tokens reports and mentra enforces:
cache reads and cache writes are counted by neither, because they are
priced differently everywhere and a total that mixed them would answer
no question exactly.
Sourcepub fn with_token_allowance(&self, additional_tokens: u64) -> Self
pub fn with_token_allowance(&self, additional_tokens: u64) -> Self
A view that allows at most additional_tokens more spending from now.
The view shares this pool’s counter: spending through this pool, the
view, or any sibling handle consumes the same allowance. Its stopping
threshold is the smaller of this pool’s limit and the current spend
plus additional_tokens, so deriving a view can tighten an allowance
but can never extend its parent. Addition saturates at u64::MAX.
This is a live bound, not a reservation. Concurrent sibling spending therefore leaves fewer tokens for work using the view.
Sourcepub fn spent(&self) -> u64
pub fn spent(&self) -> u64
What every run drawing on this pool has reported spending so far.
Live: a fan-out still in flight moves this between two reads.
Sourcepub fn remaining(&self) -> u64
pub fn remaining(&self) -> u64
What is left, saturating at zero.
Zero rather than a negative number or a wrap, because a pool is
overspendable — the round that crosses the line finishes, and a caller
can record more than was ever granted. “Nothing left”
is the honest answer to all of those; spent against
limit is where the size of the overshoot is legible.
Sourcepub fn is_exhausted(&self) -> bool
pub fn is_exhausted(&self) -> bool
Whether a turn drawing on this pool would be refused.
True the moment reported spending reaches the limit — the same comparison mentra makes at a round boundary, so this and the thing that stops a turn cannot disagree.
Sourcepub fn spec(&self, prompt: impl Into<String>) -> RunSpec
pub fn spec(&self, prompt: impl Into<String>) -> RunSpec
One run’s worth of intent, bounded by this pool.
The shorthand the fan-out is written in: workspace.prepare(pool.spec(p))
mints a run every turn of which draws here.
Sourcepub fn bounds(&self) -> TurnOptions
pub fn bounds(&self) -> TurnOptions
Turn options that bound a single call to this pool and say nothing else.
For the turns a spec does not cover: a second prompt on a conversation,
or a call that also carries a stop token —
TurnOptions::with_budget composes with the rest.
Sourcepub fn record(&self, usage: RunUsage) -> u64
pub fn record(&self, usage: RunUsage) -> u64
Charges the pool for spending it did not meter itself, returning the new total.
Not a settlement. A run this pool bounded has already been counted,
round by round, as it ran — passing its RunReport::usage here would
bill the job twice, and so would passing a delegated subagent’s usage,
which now reports into this same counter on its own. This is for work
that spent against the same allowance without ever drawing on the pool:
a run bounded some other way, or a call the host made itself.
Saturates rather than wrapping, so an absurd figure cannot roll the counter over and hand the job a fresh allowance.
Trait Implementations§
Source§impl Clone for BudgetPool
impl Clone for BudgetPool
Source§fn clone(&self) -> BudgetPool
fn clone(&self) -> BudgetPool
1.0.0 (const: unstable) · Source§fn clone_from(&mut self, source: &Self)
fn clone_from(&mut self, source: &Self)
source. Read moreSource§impl Debug for BudgetPool
Prints the figures rather than the handle, so a RunSpec or TurnOptions
carrying a pool debugs into something a caller can read.
impl Debug for BudgetPool
Prints the figures rather than the handle, so a RunSpec or TurnOptions
carrying a pool debugs into something a caller can read.
impl Eq for BudgetPool
Source§impl PartialEq for BudgetPool
Accounting identity plus bound: two handles are equal when they share one
counter and stop it at the same limit.
impl PartialEq for BudgetPool
Accounting identity plus bound: two handles are equal when they share one counter and stop it at the same limit.
Comparing the numbers instead would make two independent 500k allowances
equal while they are both untouched and unequal a moment later, which
describes no useful question. Comparing only the counter would make a
tighter BudgetPool::with_token_allowance view equal to its parent even
though the two stop at different thresholds. This definition lets
RunSpec keep its derived PartialEq: two specs are equal only when their
accounting and bound are both the same.
Auto Trait Implementations§
impl Freeze for BudgetPool
impl RefUnwindSafe for BudgetPool
impl Send for BudgetPool
impl Sync for BudgetPool
impl Unpin for BudgetPool
impl UnsafeUnpin for BudgetPool
impl UnwindSafe for BudgetPool
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
Source§impl<T> CloneToUninit for Twhere
T: Clone,
impl<T> CloneToUninit for Twhere
T: Clone,
Source§impl<Q, K> Equivalent<K> for Q
impl<Q, K> Equivalent<K> for Q
Source§impl<Q, K> Equivalent<K> for Q
impl<Q, K> Equivalent<K> for Q
Source§fn equivalent(&self, key: &K) -> bool
fn equivalent(&self, key: &K) -> bool
key and return true if they are equal.