Repository navigation
fix(sleep): enforce max_tokens_per_night in the nightly cycle - #317
Yifan Yang (Yif-Yang) merged 2 commits 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
|
@microsoft-github-policy-service agree |
|
The documented step-boundary budget is useful, and I am not treating its disclosed in-step overshoot or estimated accounting as a hidden hard-cap promise. The existing budget selection passes 16 tests plus 9 subtests. A supported route still violates the stated scheduling bound: with a budget exhausted by the initial validation/training step, the actual Claude tool route makes another uncached provider call for final_val after both edit steps were skipped. The same occurs on a timeout path, and persists when combined with #251; all four provider-boundary cases reproduce it. These are synthetic external-process responses, not paid calls. The final replay is inherited code, but the new budget contract assumes it is always a free cache hit, which this tool route is not. Please reuse already-collected validation evidence or otherwise handle uncached final scoring within the declared budget policy, and test actual provider counts rather than only a caching mock. This can preserve the intended step-based design. #313 should stay open until that boundary and the official checks are addressed. |
The budget check assumed the final validation replay was free because it re-scores documents an earlier replay already scored. That holds for cached text replies, but tool replay is never cached and a failed or timed out call is not kept in the cache, so on those routes the final replay started new provider calls after both edit steps had been skipped. Check the budget before the final validation replay as well. When it is spent, decide from the validation replay that already scored the final skill and memory (the baseline replay, or the trial replay of the last kept edit) instead of starting a new one, and list the skipped replay in the report note and the budget skip events. Nights that never reach the budget still run the fresh final replay as before. The new tests drive ClaudeCliBackend through its real tool and text routes with a synthetic process standing in for the claude CLI and count every process it starts, for answered and timed out calls.
|
Thanks for checking this against the real tool route, you were right. The final validation replay was the one step the budget never checked, and I had assumed it was free because it re-scores documents an earlier replay already scored. That only holds for cached text replies. Tool replay is never cached, and with #251 a failed or timed out call is not cached either, so on those routes the replay started the claude process again after both edit steps had been skipped. New commit 0322fbe (
The new tests drive
Tests, in
Each cycle test also checks there is no Results (Ubuntu, same commands as CI): The branch still merges cleanly with main, so I have not merged main into it; happy to if you prefer. About the official checks: nothing failed. The CI run for 356f8c8 (run 38626371053) is |
|
Merged as Re-reviewed The actual merged main This closes #313 for the documented stage-boundary limiter. The already-declared in-step overrun, backend-counter limitations and lack of a wall-clock or hard monetary cap remain explicit. Reusing a complete retained validation is the stated trade-off; this does not promise a per-call hard cap. Thank you for addressing the specific uncached-validation gap without weakening evidence integrity. |
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,The budget is also checked before the final validation replay. When it is spent, the gate decides from the validation replay that already scored the final skill and memory (the baseline replay, or the trial replay of the last kept edit) instead of starting a new one, because tool replay and failed calls are not served from the response cache. The trade-off is that on a budget-stopped night a kept edit is not re-confirmed by a second replay.
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.