Repository navigation
fix(sleep): enforce max_tokens_per_night in the nightly cycle - #317
Open
Yashvardhan Thanvi (Thanatos9404) wants to merge 1 commit into
Open
Yashvardhan Thanvi (Thanatos9404) wants to merge 1 commit into
Yashvardhan Thanvi (Thanatos9404) wants to merge 1 commit into
Conversation
The config default and the Budget controller existed, but nothing in the engine read the key or constructed a Budget, so a night ran every step no matter how many tokens it had already used. Build a Budget from max_tokens_per_night at the start of each cycle and check it, against the backend's token counter, before each step that would start new model work: the consolidation, its skill and memory edit steps, the held-out test replay and every per-skill fan-out group. Steps that never started are listed in a report note, in budget skip evidence events and in the cycle end event. A started step always finishes, so a gate decision never rests on partial evidence and nothing is cut off mid-write. A handoff round counts unanswered prompts and placeholder replies, so when the budget is reached while prompts are still pending the round pauses for answers instead of stopping. Otherwise it would stage and save a night built from placeholders. If the budget is already spent before consolidation, the night records no held-out scores instead of zeros. 0 or null keeps the night unbudgeted, matching Budget's existing semantics. Negative, boolean, fractional, infinite or non-numeric values fail before any model call. Only tonight's spend counts, so a backend reused across nights is not stopped early. Fixes microsoft#313
Author
|
@microsoft-github-policy-service agree |
This branch has not been deployed
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.
Fixes #313
Behavior change first
The default
max_tokens_per_nightis 400,000, and with this change it takes effect. Attempt prompts embed the whole skill and the wholeCLAUDE.md, so on the CLI backends a 40-task night with a 12 to 20 KBCLAUDE.mdcomes to roughly 300k to 500k estimated tokens by our count. Nights in that range will now skip their remaining steps and say so in the report. Setting"max_tokens_per_night": 0restores the previous behavior.If you would rather not change behavior for existing users, the alternative is a one-line change of the default to
0, which makes the budget opt-in. I am happy to switch to that, and to add aCHANGELOG.md[Unreleased]entry in either case.Problem
skillopt_sleep/config.pysets"max_tokens_per_night": 400_000, andskillopt_sleep/budget.pyhas aBudgetcontroller that is meant to stop cleanly and report what it skipped. Onmainnothing connects them: the key is never read outsideDEFAULTS, andBudgetis only constructed by the gbrain experiment harness and its unit test. A night runs every step regardless of how many tokens it has already used.The new regression test shows it with a deterministic backend that bills 100 tokens per uncached call. With
max_tokens_per_nightset to 1,200, the night onmainspends 2,500 tokens: after the budget is reached it still reflects, runs the skill gate trial and replays the training tasks again. With per-skill fan-out and a budget of 1, it runs the full consolidation and both groups (three reflect calls).Root cause
skillopt_sleep/config.py:43defines the key, butrun_sleep_cycle(skillopt_sleep/cycle.py) never reads it.Budget(skillopt_sleep/budget.py:15) is never created in the engine, soconsolidate(), the held-out test replay andconsolidate_groups()have no way to know the budget is spent.Change
run_sleep_cyclenow builds aBudgetfrommax_tokens_per_nightat the start of the night and checks it before each step that would start new model work:Once the budget is reached, steps that have not started are skipped and the night records it:
report.mdandreport.jsonget one note, for examplemax_tokens_per_night reached (431870 of 400000 tokens); skipped: memory edits.budgetskipevidence event, thecycleendevent carriesmax_tokens_per_nightandbudget_skipped, andmax_tokens_per_nightis added to the logged cycle config.skippedrow with the reasontoken budget for the night was reached. A group that started and was cut short keeps its own gate result and says which step it skipped.gate_action="budget_exhausted", no proposal is staged,report.mdshows no held-out score instead of0.000, and the night is recorded instate.jsonanddiagnostics.jsonwithout scores. The plain-text CLI summary still printsheld-out 0.000 -> 0.000 => budget_exhausted, andreport.json(and the--jsonoutput) keep the default0.0scores next togate_action: budget_exhausted, the same way a night with no tasks prints zeros. I left those to keep the change small and avoid changing theSleepReporttype; happy to change them if you prefer.Why step boundaries
A step that has started always finishes. That keeps the tokens already spent useful (a skill edit that passed its gate trial is still staged) and keeps the change small, and every accept or reject still rests on a complete validation replay. Staging, state and adoption writes are never skipped. Stopping at individual calls and discarding the unfinished step would make the cap tighter; that could be a follow-up if you want it.
The cost is overshoot. In the worst case a night ends above the budget by one step:
dream_rollouts=K, the skill step runs K attempts per training task before its reflect call and validation replay,After a skipped step, the final validation replay only re-scores documents an earlier replay already scored, which the CLI backends serve from their response cache.
Handoff backend
A
handoffround counts unanswered prompts and their placeholder replies. If the budget were applied to that count, a round could skip its reflect step, finish on placeholder replies, stage a report and savestate.jsonwhile still exiting 3. So when the budget is reached while prompts are pending, the budget check raisesPendingCallsand the round pauses for answers like any dependent call. A round only stops on the budget once every counted call has a real answer. When the budget is not reached, or is disabled, handoff rounds behave exactly as before.Values
Budgetalready treats a missing or zero limit as unlimited, andlookback_hoursalready uses 0 for "no limit", so0andnulldisable the budget. Negative numbers, booleans, fractional or infinite floats (including JSONInfinity) and non-numeric strings raiseValueErrorbefore any model call, like the existing split-fraction validation.Reuse and compatibility of the internals
The change reuses
Budgetas it is.consolidate(),dream_consolidate()andconsolidate_groups()take an optionalbudget_exhaustedcallable that defaults toNone, so the experiment harnesses and other callers behave exactly as before.ConsolidationResultgets one optional trailing field,budget_skipped, which keeps positional construction working. Only tonight's spend counts (Budget.startrecords the backend's total when the night begins), so a backend object reused across nights is not stopped early.A night that stops on the budget still advances the harvest checkpoint, as every other outcome does; otherwise a night whose mining alone used up the budget would mine the same sessions again every night. Skipped work is not retried later, except that tonight's tasks stay in the task archive that
recall_kdraws on. The docs say this.How spend is counted
The budget uses the backend's token counter, the one the report header prints as
tokens used. The header shows the backend's running total and the note shows tonight's spend; they only differ when a caller reuses one backend object across nights. This PR does not change how tokens are counted, and the counter has gaps that the new docs section lists:dream_rolloutsabove 1,rollout.py) callbackend._calldirectly and are not counted.azure_openaiadds the API's reported usage inside_call, andCliBackend._cached_callandreflectthen add the length estimate for the same call, so replay and reflect calls count roughly twice while mining is counted once. fix(sleep): thread-safe backend cache + redact exports #251 also reworks per-attempt token accounting.handoffcounts the prompt and answer text of the current round.mockreports no usage.There is no elapsed-time budget.
Tests
New
tests/test_sleep_token_budget.py(stdlibunittest, no network). The main backend is aMockBackendthat bills a fixed cost per uncached attempt and per reflect call and, like the CLI backends, serves repeated attempts on unchanged documents from cache.report.mdshows it and the evidenceendevent lists them.budget_exhausted, no0.000in the report header, no scores instate.jsonhistory ordiagnostics.json.0,null,"0"and0.0behave exactly like the default budget on an ordinary night (same calls, accepted, no note); the default 400,000 leaves that night unchanged.skippedrows with the budget reason and a skip event each, no reflect runs and no group replays against its own skill document.-1,"lots",0.5,True,infand a JSONInfinityraise before any model call.skillopt-sleep run --backend handoffwithmax_tokens_per_night: 1in the config file exits 3 withPROMPTS.md, writes nostate.jsonand stages no report.PendingCalls; once its prompts are answered, the second round applies the budget, skips the skill and memory edits, has nothing pending and records night 1.consolidate()with a spent budget makes no call and returnsbudget_exhausted; with a budget that is never reached it makes the same calls and decision as without one.consolidate_groups()skips groups that have not started and marks a group that was cut short.Results (Ubuntu, Python 3.10 and 3.12, same commands as CI):
ruff checkon the changed files reports only the import-order warning thatskillopt_sleep/cycle.pyalready has onmain.Docs
docs/sleep/README.md: new "Nightly token budget" section: what is checked, the report note, valid values, worst-case overshoot, how each backend counts spend, and that a budget-stopped night still advances the harvest checkpoint.plugins/README.mdandplugins/claude-code/README.md: the safety lists no longer say the CLI has no token budget; they describemax_tokens_per_nightand say it is not a hard cap and there is no wall-clock budget.plugins/openclaw/README.md: no longer saysmax_tokens_per_nightis not enforced. The wrapper'srun_sleep.pypasses itsconfig.json(800,000) to the shared cycle, so it now applies, measured with that backend's own rough estimate.Notes for maintainers
343db229dbd5ddaf9df6b1d5540d8bcdb2604d5c, head356f8c8096aa3b73240a1e71a2fd6548dd293788, one commit.dream_consolidatesignature and call; whichever lands second needs a trivial merge that keeps both sets of keyword arguments. If feat(sleep): add adversarial candidate probes #263 lands, its adversarial probe replays would be another step to check the budget before.