fix(own-loop): budget thresholds use projected context, not cumulative cost - #77
Merged
Merged
Conversation
…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.
This was referenced May 25, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
inputTokensis ~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):At step 8 the actual next-call message array was ~20k. Model had ~180k of room.
How
cumulativeInputTokensis 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 withexitReason: budget-limitand 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:
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
memory/learnings/dodo-orchestrator-model-matters.mdbeep-boop-🤖