Skip to content

fix(own-loop): budget thresholds use projected context, not cumulative cost - #77

Merged
jonnyparris merged 1 commit into
mainfrom
fix/projected-budget-thresholds
May 25, 2026
Merged

fix(own-loop): budget thresholds use projected context, not cumulative cost#77
jonnyparris merged 1 commit into
mainfrom
fix/projected-budget-thresholds

Conversation

@jonnyparris

Copy link
Copy Markdown
Owner

What

Make the wrap-up / hard-stop / warn thresholds fire on the size of the message array we're about to send to streamText, not on the running sum of input tokens we've spent this turn.

Why

The two metrics coincide for providers with prompt caching (Anthropic) but diverge sharply for providers without it (Workers AI: Gemma, Kimi). For a typical Gemma session each per-step inputTokens is ~20k regardless of how much real history exists — system prompt + tool definitions dominate the per-call cost. By step 8 the cumulative sum is ~158k (77% of the 205k budget) and the wrap-up injection fires telling the model to stop. But the actual message array for the next call is also ~20k — the model has ~180k of context window free.

Today's reproduction (commit 41324ab):

step 0  cumulative=19569   budget=10%
step 1  cumulative=39208   budget=19%
step 2  cumulative=58895   budget=29%
step 3  cumulative=78795   budget=38%
step 4  cumulative=98815   budget=48%
step 5  cumulative=118955  budget=58%  ← mid-loop compaction attempted (noop)
step 6  cumulative=138524  budget=68%
step 7  cumulative=158162  budget=77%  ← budget warning injection fires
step 8  cumulative=177894  budget=87%  ← wrap-up injection fires
        Gemma reads 'context budget nearly exhausted' and stops.

At step 8 the actual next-call message array was ~20k. Model had ~180k of room.

How

// Before
const budgetUsage = cumulativeInputTokens / tokenBudget;

// After
const projectedNextCallTokens = estimateMessagesTokens(messages);
const budgetUsage = projectedNextCallTokens / tokenBudget;

cumulativeInputTokens is kept for telemetry — still logged on every step-complete and on the threshold injections — but no longer gates them.

Cost runaway backstop

Without cumulative-based gating, a model in a non-doom-loop loop could burn unbounded tokens. New absolute backstop: cumulativeInputTokens >= tokenBudget * 5 (e.g. 1M tokens for a 200k model) exits with exitReason: budget-limit and a 'cost runaway' message. Generous for real multi-step work, tight enough to catch billing bombs.

What this unblocks

Gemma sessions that previously stopped at step 8 with 'context exhausted' should now keep iterating until either:

  • the model decides it's done
  • per-call projected context actually fills up
  • the cost runaway backstop fires

Strong models with prompt caching won't notice (cumulative ≈ projected for them). Weak models with no caching get the full benefit.

Tests

All 28 existing compaction + prune tests pass. Typecheck clean.

The budget threshold logic isn't directly unit-tested (it lives inside onChatMessage's generator). Behavioural validation via post-deploy Gemma re-run on the failing prompts.

Linked

beep-boop-🤖

…e cost

The wrap-up / hard-stop / warn thresholds were gating on
`cumulativeInputTokens / tokenBudget` — the sum of input tokens billed
across every iteration of the turn. That's a **cost** signal, not a
**context window pressure** signal. The two coincide when the model
has prompt caching (Anthropic) but diverge sharply for providers
without it (Workers AI: Gemma, Kimi).

For a Gemma session today the per-step `inputTokens` was ~20k regardless
of how much real history existed (system prompt + tool defs dominate).
By step 8 cumulative was 158k (77% of the 205k budget) and the wrap-up
injection fired — telling the model to stop because it was 'nearly out
of context'. But the actual next-call message array was ~20k, leaving
180k+ of context window free.

## Fix

`budgetUsage` now measures `estimateMessagesTokens(messages) / tokenBudget` —
the size of the message array we'd send NEXT, which is what the system
prompt actually talks about ('context budget nearly exhausted').

`cumulativeInputTokens` is retained as telemetry (logged on every
step-complete and threshold log) but no longer gates injections.

## Cost runaway backstop (new)

Without cumulative-based gating, a model stuck in a non-doom-loop loop
(tools succeeding, results changing, but no real progress) could burn
unbounded tokens. Added an absolute backstop: when
`cumulativeInputTokens >= tokenBudget * 5` (1M tokens for a 200k-budget
model) the loop exits with a 'cost runaway' message. Generous for real
multi-step work, tight enough to catch obvious billing bombs.

## What this unblocks

Gemma sessions that previously stopped at step 8 with 'context exhausted'
should now keep iterating. Projected context stays low because tool
results are small; the model has plenty of room.

## Trade-off

If projected (next-call) tokens stay below 70% but cumulative climbs
forever, the cost runaway backstop catches it at 5× tokenBudget. Strong
models with prompt caching won't notice the change (cumulative ≈
projected for them). Weak models with no caching get the fix's full
benefit.

## Tests

All 28 existing compaction + prune tests pass. Typecheck clean.
The budget threshold logic isn't directly unit-tested (it lives inside
onChatMessage's own-loop generator). Behavioural validation via post-deploy
Gemma re-run on the failing prompts.
@jonnyparris
jonnyparris merged commit 8e32703 into main May 25, 2026
1 check passed
jonnyparris added a commit that referenced this pull request Aug 29, 2026
…tion loops

Adopts the enforcement-seam shape (Flue's useAgentFinish) inside Dodo's own
loop without the dependency. All guardrail decisions (doom loop, same-tool
repetition, cost runaway, budget tiers, compaction/prune triggers, text and
no-text watchdogs, phase transitions, continuation digest) now live in
src/loop-policy.ts as total functions; coding-agent.ts only applies effects.

The primary loop and continuation phases previously ran two hand-copied
variants of the same logic — the phase loop had silently drifted and was
missing same-tool repetition checks, the cost runaway backstop, projected
budget gating (still used the pre-#77 cumulative signal), mid-loop
compaction, pruning, and overflow recovery. One unified runPhase() closes
that gap; phases now get the same guardrails as the primary loop.

Deliberate behaviour changes (both improvements, noted for the record):
- phase exits on stuck signals now get a final-summary turn, which only
  the primary exit got before
- phase overflow errors attempt emergency compaction instead of breaking

Adds loop-policy-unit.test.ts (26 tests) covering thresholds, injection
precedence, buffer retention, and the phase digest builder.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant