Repository navigation
Token soft threshold: dynamic handoff reserve, cheaper handoff, richer emergency note #159
Description
Problem
On the #155 attempt (2026-10-02T07:01:29.709272Z), the token soft threshold fired. The model invoked the handoff skill but hit the hard ceiling before it wrote the handoff note. The runtime then committed its emergency note (reason: token_hard_ceiling), whose only remaining-work entry is "Review progress preserved on this branch and continue implementation." The continuation has nothing to go on, and the work in lifecycle.py, git_workspace.py and tests/test_working_tree_hold.py is undocumented.
Timeline from agent_output.json:
| UTC | event | budget_tokens |
|---|---|---|
| 07:08:56 | token_soft_threshold_crossed (soft = 3 200 000), in-flight Edit gets no result |
3 327 377 |
| 07:09:02 | skill_call handoff, token_soft_threshold_handoff_context_injected |
3 474 876 |
| 07:09:11 | git status --short |
3 623 132 |
| 07:09:32 | git log --oneline -3 |
3 771 772 |
| 07:09:38 | git branch --show-current |
3 920 596 |
| 07:09:46 | token_hard_ceiling_reached (max = 4 000 000) |
4 069 634 |
Root cause: the gap between the soft threshold and the hard ceiling is a fixed share of the budget (SOFT_THRESHOLD_PERCENTAGE=0.2, so 800k tokens). The cost of one turn, however, grows with the context, because every turn re-counts the whole main context (input + cache_read). Late in the run, main_context_tokens was about 148k, so 800k covered only ~5.4 turns. The threshold is also latched after the turn that crosses it, which here was already 127k past the threshold, leaving ~4.5 turns. The model spent them on separate orientation commands (git status, git log, git branch, one per turn) before writing anything. Poor prompt caching (prompt_cache_ineffective, main_hit_rate=0.2752, two turns at cache_hit_rate=0.0 just before the crossing) made the run reach the threshold sooner, but it doesn't change the per-turn arithmetic.
Proposed changes
1. Dynamic handoff reserve
Make the soft threshold a reserve sized in turns at the current context size, not a fixed share of the budget:
soft_threshold_tokens = max_budget_tokens - reserve_turns * main_context_tokens
(Optionally keep the percentage as a floor: trigger when either rule fires.)
- Evaluate it before dispatching the next turn as well (predictive:
budget + reserve_turns * main_context_tokens >= max_budget_tokens), so the crossing doesn't overshoot by one full turn. - Default
reserve_turnsaround 6–8. Log the effective reserve inlimits_checkedandtoken_soft_threshold_crossed(e.g.reserve_tokens=,reserve_turns=,main_context_tokens=). token_budgetinattempt.jsonshould record which rule triggered the threshold.
2. Simplify the handoff for the model
Reduce the handoff to as few tool calls as possible:
- When injecting the handoff context (
_cost_handoff_instruction/_cost_handoff_followup_prompt), include the state the model would otherwise look up: current branch, base revision,git status --short,git log --oneline <base>..HEAD,git diff --stat <base>, thestarted_attimestamp, the issue number and the expectedreason. The model then doesn't need to orient itself. - Update
skills/handoff/SKILL.mdto say that this state is already provided, and to do the whole handoff in at most ~2 tool calls: oneWriteof the note, then a singleBashthat commits work and the note as two separate commits (git add -A ':!.agent/handoff' && git commit -m … ; git add .agent/handoff/<n>.md && git commit -m "Handoff note: issue #<n>"). Tell the model not to run diagnostic commands first. - Consider tightening the handoff-phase tool guard: once the soft threshold is crossed, deny read-only orientation commands (or just answer them with the pre-supplied state) so they can't use up the reserve.
3. Richer runtime emergency note
When the model doesn't write a valid note, _build_emergency_handoff_note should give the continuation as much as the runtime can reconstruct without the model:
- Explicit provenance: state that the runtime generated the note because the model didn't write one, and say which limit fired.
- Change summary:
git diff --stat <base>..<seal commit>, the list of work commits (git log --oneline <base>..HEAD), and which files were uncommitted at the moment of sealing (from the seal commit). - Last model intent: the last assistant text message (truncated) and the handoff skill status (
invoked/not invoked). - Activity trail: the files the model edited or created during the attempt (from Edit/Write tool calls), the last N tool calls (name + short target), and the last test/check command the model ran with its exit status, if any.
- Check state: whether the final check was run, and its result, if the runtime has it.
- Budget facts:
budget_tokens,max_budget_tokens, the soft-threshold crossing point,peak_main_context_tokens, prompt-cache hit rate, turns and elapsed time, so the continuation (and the operator) can tell why the reserve ran out. - Pointers: the attempt log directory path and the transcript session id.
- Remaining work: the issue's acceptance criteria / checklist items copied from the issue body, flagged as "not verified by the runtime", instead of the generic placeholder.
All of this must remain parseable by the existing note validator (issue, started_at, reason and last_work_commit fields unchanged).
4. Token limits configurable through .env, documented correctly
MAX_BUDGET_TOKENS and SOFT_THRESHOLD_PERCENTAGE are already read in config.py, but .env.example is out of date: it describes the soft threshold relative to MAX_BUDGET_USD (soft = max_budget_usd - max_budget_usd * percentage) and doesn't list MAX_BUDGET_TOKENS at all.
- Document
MAX_BUDGET_TOKENS,SOFT_THRESHOLD_PERCENTAGEand the new reserve setting (e.g.HANDOFF_RESERVE_TURNS) in.env.examplewith their defaults and the formula actually used. - Validate them at startup (e.g. reject a reserve that leaves the soft threshold ≤ 0 for a typical context) and log the effective values in the startup event.
- Keep
docs/agents/operator-onboarding.mdconsistent with the new formula.
Acceptance criteria
- With a ~150k-token main context and default settings, the soft threshold fires early enough to leave at least
reserve_turnsturns before the hard ceiling (unit test on the threshold calculation, including the predictive pre-turn check). - The handoff context injected after the soft threshold contains the branch, status, commits and diff stat. The handoff skill tells the model to finish in ≤2 tool calls.
- An attempt that hits the hard ceiling without a model-written note produces an emergency note with the sections from item 3, and it passes the note validator (tests).
.env.exampleand the operator docs describeMAX_BUDGET_TOKENS,SOFT_THRESHOLD_PERCENTAGEand the reserve setting accurately. Config parsing tests cover the new variable.
Activity
lbacik commentedon Oct 8, 2026
Triage outcome
Checked against the code: nothing proposed here exists yet, and the root cause holds. The soft threshold is a fixed share of the budget, it is latched only after the crossing turn, and the emergency note has a generic remaining-work line. Grilled into three independent sub-issues, each with an agent brief:
Decisions that differ from the original proposal (recorded in ADR 0004 and in
CONTEXT.mdon branchdocs/issue-159-handoff-reserve):HANDOFF_RESERVE_TURNS(default 6) turns at the latest main-context size, plus the turn in flight. There is no separate predictive check, because the runtime has no pre-turn hook; the check also runs in PreToolUse.last_work_commitmust be the commit beneath the note, and the model doesn't know that SHA before committing its work. Instead, after a token soft threshold the model commits only the note, withlast_work_commitset to the HEAD the runtime supplies, and sealing commits the remaining work on top. That flow is oneWriteplus oneBash.This parent stays open to track the sub-issues.
Record handoff reserve decisions for #159 (ADR 0006, glossary)
Merge pull request #235 from lbacik/docs/issue-159-handoff-reserve