Skip to content

fix: context budget guard + no-window warning (#347) - #350

Merged
ranxianglei merged 5 commits into
masterfrom
2026-08-28_context-budget-guard
Sep 10, 2026
Merged

ranxianglei merged 5 commits into
masterfrom
2026-08-28_context-budget-guard

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

What

Fixes #347 (billion-context#317): sessions whose input grew past the backend's real context window were rejected with HTTP 400, which opencode swallows as an empty exit-0 response — permanently stuck session, no error surface.

Root cause chain (full analysis in #317): a custom model with no limit entry → modelContextLimit stays undefined → every percentage threshold (min/max/emergency, GC) is disabled → only advisory nudges remain → input grows unbounded until the backend 400s (production case: 230,527 input + 32,000 completion > 262,144 window).

Changes

  1. Deterministic prune-to-fit guard (lib/messages/enforce-budget.ts, new): in messages.transform, after the existing GC truncation, if the estimated request exceeds modelContextLimit - completionReserveTokens, old compressible tool outputs are truncated (prefix+suffix, same marker as truncateLargeToolOutputs for idempotency) and, if still over, cleared to the standard placeholder. Protections: first user message, last 3 messages, protectedTools, compress-tool outputs (summaries).

    The guard enforces ONLY the model-reported window. An absolute compress.maxContextLimit is deliberately not a fallback — it is a soft nudge threshold, not the backend's real limit; pruning to it would destroy context the backend would accept (this regressed e2e-blocks-nudges during development: the guard pruned the one large tool output below the recommendation floor → nothingToCompress → no nudge suffix).

  2. Loud one-time WARN (lib/hooks.ts): when the model reports no context window and the catalog has no entry, log actionable guidance once per session (set the model limit in opencode.json — this both enables the guard and fixes the 32,000 max_tokens fallback — or set an absolute compress.maxContextLimit for proactive nudges).

  3. New config compress.completionReserveTokens (default 32768, schema + EN/CN docs): reserved for the completion; covers opencode's 32,000 max_tokens fallback when limit.output is 0/unknown.

  4. Transient state noContextLimitWarned (not persisted; default/reset in createSessionState/resetSessionState).

Pre-flight

  • npm run typecheck — clean
  • npm test — 1043/1043 pass (14 new tests in tests/enforce-budget.test.ts)
  • npm run build — success
  • Regression check: tests/e2e-blocks-nudges.test.ts 10/10

Notes

@github-actions

github-actions Bot commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

📦 Built Plugin Artifact

Branch: 2026-08-28_context-budget-guard (fc7e921)

Option A — Install from npm PR tag (recommended)

opencode plugin opencode-acp@pr-350 --global

Each push to this PR publishes a new version under the pr-350 npm tag.

Option B — Install from GitHub

opencode plugin "github:ranxianglei/opencode-acp#2026-08-28_context-budget-guard" --global

Option C — Download artifact

  1. Download the artifact from the Actions run
  2. Extract the tarball and install:
tar xzf opencode-acp-pr350.tgz
cp -r package/dist ~/.cache/opencode/packages/opencode-acp@latest/node_modules/opencode-acp/dist
  1. Restart opencode to pick up changes.

This comment is automatically updated on each push.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 ework agent · vllm-qwen/qwen3.8-27b

[bot] Picked up PR #347 (2026-08-28_context-budget-guard, tip 0fa4e24) for review. Initial local verification so far:

  • npm run typecheck — clean
  • npm test — 1043/1043 pass (incl. the 14 new tests in tests/enforce-budget.test.ts)
  • npm run build — success (dist/index.js 419.61 KB)
  • tests/e2e-blocks-nudges.test.ts — 10/10 (the regression the PR calls out)
  • ./scripts/ci/check-pr.sh 2026-08-28_context-budget-guard origin/master — all checks pass (branch name, devlog REQ+WORKLOG, no version bump)

Now running the mandatory dual-agent review (source per AGENTS.md §5.3, tests per §5.6) plus a few consistency checks (devlog commit hash vs branch tip, PR/CI state on GitHub). Full review verdict will follow as a second comment.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 ework agent · vllm-qwen/qwen3.8-27b

[bot] Review verdict: REQUEST-CHANGES → fixed & pushed. Two independent agent reviews (source + tests) of the PR branch found 1 blocker and several real bugs. All fixed, pushed as two commits on 2026-08-28_context-budget-guard (318c94d, 18debdb).

BLOCKER (fixed): compress.completionReserveTokens was documented in the schema/EN/CN docs but missing from VALID_CONFIG_KEYS (lib/config-validation.ts) — anyone who set the new key would get a spurious "Unknown keys" toast. Added to the whitelist + validateConfigTypes (number, >= 0).

Other bugs found & fixed:

  1. NaN reserve hazard — config validation is advisory-only (warns, doesn't block load), so a non-numeric completionReserveTokens made budget NaN, which made every <= budget early-exit false → the guard would truncate all candidates with no stop. Now falls back to DEFAULT_COMPLETION_RESERVE_TOKENS when the value isn't a finite number >= 0.
  2. estimateWireTokens undercount (two parts) — it anchored additions on the last assistant by role, while getCurrentTokenUsage anchors on the last assistant with token data (it skips tokenless aborted requests); the gap between the two was undercounted. Additionally, when getCurrentTokenUsage itself fell back to content estimation, the system-prompt estimate was silently dropped. Both fixed.
  3. Truncation could GROW content — for output just over the 4000-char threshold, prefix + suffix overlap plus the marker line made the "truncated" form longer than the original. Now skipped (phase 2 may still clear it).
  4. Docs contradicted code — dcp.schema.json, CONFIGURATION.md, CONFIGURATION.zh-CN.md, and the warn-once text all claimed an absolute compress.maxContextLimit enables the guard. It deliberately does not (soft nudge threshold, not the backend's real limit — the e2e regression you noted). All four corrected.

Left as documented known limitations (low impact, noted in WORKLOG): the one-time no-window warning can fire one turn early for a catalog-unknown model that declares its limit (transform ordering); first-user-message protection is index-0-only (matches truncateLargeToolOutputs); the "still over budget" WARN repeats while the condition persists (deliberate — it's a genuinely alarming state).

Tests: +9 new (23 total in tests/enforce-budget.test.ts; 4 in tests/config-validation.test.ts) covering budget<=0 no-op, <3 messages, non-numeric reserve fallback, the tokenless-aborted-request estimate gap, and the non-shrinking truncation skip. The fragile clear-phase test premise (a ~10% BPE margin) was rebuilt so phase 1 provably saves 0 by char-count fact; the weak fallback-estimate assertion was tightened to near-exact equality.

Verification (all on the pushed branch):

  • npm run typecheck — clean
  • npm test — 1052/1052 pass
  • npm run build — success (dist/index.js 420.69 KB)
  • tests/e2e-blocks-nudges.test.ts — 10/10
  • ./scripts/ci/check-pr.sh 2026-08-28_context-budget-guard origin/master — all checks pass

No version bump (content branch). PR is ready — merge is a human-only operation per project rules, so I'll leave that to you.

ework-agent added 5 commits September 10, 2026 23:05
In headless per-message spawn+resume mode the model context limit was
never known when the messages-transform pipeline ran: the system hook
(its only writer) runs after messages.transform within a request and
never persisted its value, and the init-time catalog seed races server
readiness. Every percentage threshold therefore resolved to undefined
and the entire safety net (nudges, GC, in-flight truncation) was
silently disabled, letting sessions grow to the length-rejection wall
(~229k tokens on a 262144 window) in an infinite empty-response retry
loop.

- Persist modelContextLimit + identity from the system hook on change
- Lazy one-shot catalog hydration during a request on catalog miss
- New compress.contextLimitFallback (default 128000, 0 disables) drives
  thresholds/GC/truncation when the model limit is unknown
- In-flight truncation becomes overhead-aware:
  min(gc threshold, limit - systemPromptTokens - 16384 output reserve)
- ERROR log ("ACP hard guard") when post-transform tokens exceed the
  model budget
- Internal-agent (title/summary/compaction) system prompts no longer
  overwrite the session limit

Tests: 1049 pass (20 new); all 11 behavioral tests verified to fail
with the source changes reverted.
Source:
- Correct the truncation-threshold comment (dual-regime rationale:
  provider-usage counts already include the system prompt — margin;
  fallback counts exclude it — exact bound)
- Throttle the 'window too small' ERROR to once per session
- hydrateAndResolve: in-flight promise instead of a boolean so
  concurrent callers share one hydration
- System hook: only write the model identity pair when present (a limit
  without identity no longer clobbers the #312 staleness pair)
- Extend contextLimitFallback docs (switch-invalidation case, per-model
  precedence) in config.ts, dcp.schema.json, CONFIGURATION.md/zh

Tests:
- Restore 7 hollowed truncation tests (tiny windows now bail before the
  protection/skip branches run) — branches execute again
- Add the §5.7 multi-turn growth-cycle test (fallback-only config,
  preserveRecentMessages 20; asserts shouldInjectThisTurn, baseline, and
  anchor sets per turn; turn-anchor assertion is the pre-fix discriminator)
- Poll for the persisted state file instead of a fixed sleep; restore
  XDG env vars in finally
- Stub hydrateAndResolve mirrors the real once-per-process hydration
- Remove the phantom 'strategies' config field from the new test factory
- Faster MEDIUM_OUTPUT fixture (2201 tokens, verified) for the new
  truncation tests; correct stale threshold comments

Verification bugs found and fixed in this round: state.sessionId is
string | null (throttle key), missing lib/messages/inject barrel import.
A model with no declared context window (limit.context=0) disabled every
percentage threshold in the plugin, so requests could grow past the
backend's real window and be rejected with HTTP 400 - which opencode
swallows as an empty exit-0 response (billion-context#317).

- enforceContextBudget: deterministic prune-to-fit guard in
  messages.transform (truncate largest old tool outputs, then clear
  oldest) using the model window or an absolute maxContextLimit,
  reserving completionReserveTokens (default 32768) for completion
- one-time WARN when the model reports no context window and the
  catalog has no entry, with actionable config guidance
- new config: compress.completionReserveTokens (schema + docs)
- 14 new unit tests
…ard, docs

Dual-agent review of the context budget guard found:
- BLOCKER: compress.completionReserveTokens missing from VALID_CONFIG_KEYS
  → spurious "Unknown keys" toast for users setting the documented key
- NaN reserve hazard: non-numeric config value made budget NaN, disabling
  every early-exit (guard would truncate all candidates)
- estimateWireTokens undercount: anchored on last assistant by role while
  getCurrentTokenUsage anchors on last assistant WITH token data; also
  dropped the system prompt estimate when getCurrentTokenUsage fell back
  to content estimation
- truncation could GROW content just over the 4000-char threshold
  (prefix/suffix overlap + marker line)
- docs (schema, EN/CN, warning text) claimed an absolute
  compress.maxContextLimit enables the guard; it deliberately does not

Tests: +9 (23 in enforce-budget.test.ts, 9 new in config-validation.test.ts);
full suite 1052/1052.
@ranxianglei
ranxianglei force-pushed the 2026-08-28_context-budget-guard branch from 18debdb to fc7e921 Compare September 10, 2026 15:12
@ranxianglei
ranxianglei merged commit 7c39c2b into master Sep 10, 2026
6 checks passed
ranxianglei pushed a commit that referenced this pull request Sep 10, 2026
Resolve single import-block conflict in lib/messages/inject/utils.ts:
master added a standalone getCurrentTokenUsage import (already present in
this branch's merged token-utils import block) plus resolveEffectiveContextLimit.
Deduped getCurrentTokenUsage; kept expanded state/utils import.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant