feat: request-side overflow guard + uncalibrated-window WARN (#347) - #348
feat: request-side overflow guard + uncalibrated-window WARN (#347)#348ranxianglei wants to merge 3 commits into
Conversation
When a model reports limit.context=0 (custom providers), state.modelContextLimit is never set so every percentage threshold resolves to undefined and silently no-ops; the session then grows past the backend's real window and dies on a provider 400 that opencode swallows (exit 0, no output) — a silent, deterministic, unrecoverable death loop. Two fixes: - Fix 1 (visibility): trackUncalibratedWindow logs a one-time per-session WARN after 3 consecutive transforms with an unresolved window, telling the user to declare limit in opencode.json or set absolute compress.maxContextLimit. - Fix 2 (hard guard): pruneToFit deterministically clears the oldest compressible (non-protected) tool outputs when the estimated wire size exceeds knownWindow - overflowGuardReserve, independent of model cooperation. knownWindow = modelContextLimit, else absolute modelMaxLimits[provider/model], else absolute maxContextLimit. Converts a hard 400 into a degraded-but-working turn. New config: compress.overflowGuard (bool, default true), compress.overflowGuardReserve (number, default 32768). New transient (non-persisted) state fields: uncalibratedWindowTransforms, uncalibratedWindowWarned. Tests: tests/prune-to-fit.test.ts (25 tests). Full suite 1054/1054 pass.
📦 Built Plugin ArtifactBranch: Option A — Install from npm PR tag (recommended)opencode plugin opencode-acp@pr-348 --globalEach push to this PR publishes a new version under the Option B — Install from GitHubopencode plugin "github:ranxianglei/opencode-acp#2026-08-28_overflow-guard" --globalOption C — Download artifact
tar xzf opencode-acp-pr348.tgz
cp -r package/dist ~/.cache/opencode/packages/opencode-acp@latest/node_modules/opencode-acp/dist
This comment is automatically updated on each push. |
Review round 1 (dual-agent) findings: - B1 (blocking): estimateWireTokens read only the last assistant's provider-reported usage, which is the context size AFTER the last LLM call and so omits tool outputs appended after that call. A mid-turn sub-request (opencode runs messages.transform on every LLM call) could be under-estimated and still 400. Now adds the last assistant's trailing completed tool outputs to the estimate (conservative: exact when the last step has text, over-counts only for tool-calls-only steps — the safe direction). Regression test added and verified to fail without the fix. - Test hardening: reworked the vacuous recent-zone test (gap now forces the guard into the zone; asserts the zone-protected message is skipped and an ERROR is logged); added a production-shape test (last assistant carrying a trailing completed tool part) covering current-turn protection; added an ERROR log for the over-budget-but-nothing-clearable case; added a test pinning an explicit overflowGuardReserve: 0 (nullish, not falsy). - Docs: DESIGN.md + WORKLOG.md updated to reflect the B1 estimator change and the review round. Suite: 1057/1057 pass, typecheck clean, build clean.
[bot] PR review complete — REQUEST-CHANGES (bounded; 1 source fix + test fixes). Two independent agent reviews per AGENTS.md §5.3/§5.6, with me re-verifying every load-bearing claim against the code. ✅ Verified green (ran locally on the branch)
🔴 MAJOR findingsM1. Stale estimate → guard over-fires on the turn after a compress — M2. "respects the recent-message protection zone" test does not verify protection — 🟡 MINOR findings
⚪ NITs
VerdictREQUEST-CHANGES. The design is sound and the change set is otherwise clean — persistence, backward compat, type safety, security, performance, and block-state interaction all check out. The fix list is bounded: M1 (post-prune estimate + regression test) is the only source-behavior change; M2 + m3/m4/m5 are test-file fixes; m1/m2 are small source improvements worth doing in the same pass. Happy to implement the fixes on the branch if you'd like — otherwise I'll re-review once updated. (Reminder per AGENTS.md §5.1.1.2: I won't merge the PR — that's yours to click.) |
…#347) - N1: use precise content count after a compress (stale provider base over-cleared) - N2: count messages after the last assistant (current user turn was uncounted) - N3: dedup the 'nothing clearable' ERROR via transient overflowGuardStuckLogged - T1: backward scan instead of array copy; T2: fix misleading docstring - T3-T6: production-shape clear test, time.created mock shape, non-completed trailing-run test, tighten loose assertion - 33 prune-to-fit tests (5 new), full suite 1062/1062, N1/N2 mutation-verified
Fixes #347
Problem
When a model reports
limit.context = 0(custom OpenAI-compatible providers),state.modelContextLimitis never set — the model-limit catalog dropslimit <= 0and the system hook guards onlimit.context. Every percentage threshold (minContextLimit/maxContextLimit/emergencyThresholdPercent) then resolves toundefinedand silently no-ops. The session grows past the backend's real window and dies on a provider400("Requested token count exceeds the model's maximum context length…") that opencode swallows — exit 0, no output — a silent, deterministic, unrecoverable death loop.Changes
Fix 1 — make the blindness visible (
lib/messages/uncalibrated-window.ts, new):trackUncalibratedWindowlogs a one-time per-session WARN after 3 consecutive transforms with an unresolved window, telling the user to declarelimitinopencode.jsonor set absolutecompress.maxContextLimit/minContextLimit. The counter resets once a window resolves.Fix 2 — request-side hard guard (
lib/messages/prune-to-fit.ts, new):pruneToFitdeterministically clears the oldest compressible (non-protected) tool outputs when the estimated wire size exceedssafeBudget = knownWindow − overflowGuardReserve, independent of model cooperation. This converts a hard 400 into a degraded-but-working turn.knownWindow=modelContextLimit(real window) → else absolutemodelMaxLimits[provider/model]→ else absolutemaxContextLimit;undefined(guard off) when only a percent is set. A percent is a nudge threshold, not a window — using it would massively over-prune.Config (validated + schema'd):
compress.overflowGuard(bool, defaulttrue),compress.overflowGuardReserve(number, default32768, covers opencode's 32000 fallback forlimit.output = 0).State: two new transient (non-persisted) fields —
uncalibratedWindowTransforms,uncalibratedWindowWarned. Old state files load unchanged.Not in scope (documented in devlog)
exit 0 on 400andoptions.maxTokens not honoredbugs — upstream.Verification
npm run typecheck— cleannpm run build— cleannpm run test— 1054/1054 pass (25 new tests intests/prune-to-fit.test.ts)scripts/ci/check-pr.sh— branch name, devlog, changelog all passDevlog:
devlog/2026-08-28_overflow-guard/(REQ + DESIGN + WORKLOG).