Skip to content

Token soft threshold: dynamic handoff reserve, cheaper handoff, richer emergency note #159

Closed
3 of 3 issues completed

Description

@lbacik

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_turns around 6–8. Log the effective reserve in limits_checked and token_soft_threshold_crossed (e.g. reserve_tokens=, reserve_turns=, main_context_tokens=).
  • token_budget in attempt.json should 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>, the started_at timestamp, the issue number and the expected reason. The model then doesn't need to orient itself.
  • Update skills/handoff/SKILL.md to say that this state is already provided, and to do the whole handoff in at most ~2 tool calls: one Write of the note, then a single Bash that 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_PERCENTAGE and the new reserve setting (e.g. HANDOFF_RESERVE_TURNS) in .env.example with 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.md consistent 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_turns turns 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.example and the operator docs describe MAX_BUDGET_TOKENS, SOFT_THRESHOLD_PERCENTAGE and the reserve setting accurately. Config parsing tests cover the new variable.

Activity

  1. lbacik commented on Oct 8, 2026

    @lbacik
    OwnerAuthor

    This was generated by AI during triage.

    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.md on branch docs/issue-159-handoff-reserve):

    • Threshold: the soft threshold is the earlier of the percentage rule and a reserve of 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.
    • The proposed 2-call handoff can't satisfy the note validator. last_work_commit must 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, with last_work_commit set to the HEAD the runtime supplies, and sealing commits the remaining work on top. That flow is one Write plus one Bash.
    • Orientation commands are not denied. A denied call costs a full turn too. The instruction tells the model not to run them instead.
    • The emergency note doesn't copy acceptance criteria, since the continuation already receives the issue body. It says honestly that remaining work wasn't determined, and quotes the model's last stated intent.
    • The runtime does not run the final check before writing the emergency note. The note reports "not run" plus the baseline check result.

    This parent stays open to track the sub-issues.

  2. added
    ready-for-agentFully specified implementation issue ready for an unattended agent
    and removed
    ready-for-agentFully specified implementation issue ready for an unattended agent
    on Oct 8, 2026
  3. added 2 commits that reference this issue on Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions