Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion dcp.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,17 @@
"type": "boolean",
"default": true,
"description": "Always protect the most recent user message from compression."
},
"overflowGuard": {
"type": "boolean",
"default": true,
"description": "Enable the request-side overflow guard (prune-to-fit). When the estimated wire size exceeds knownWindow - overflowGuardReserve, deterministically clear the oldest compressible (non-protected) tool outputs until the estimate fits. See #347."
},
"overflowGuardReserve": {
"type": "number",
"default": 32768,
"minimum": 0,
"description": "Tokens reserved for the model's completion by the overflow guard. The guard keeps safeBudget = knownWindow - overflowGuardReserve. Should be at least the model's typical max output tokens (opencode falls back to 32000 when limit.output = 0)."
}
},
"default": {
Expand All @@ -322,7 +333,9 @@
"lastSegmentSoftBlock": true,
"preserveRecentMessages": 20,
"preserveRecentTokens": 20000,
"preserveLastUserMessage": true
"preserveLastUserMessage": true,
"overflowGuard": true,
"overflowGuardReserve": 32768
}
},
"gc": {
Expand Down
111 changes: 111 additions & 0 deletions devlog/2026-08-28_overflow-guard/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# DESIGN - Request-side overflow guard + uncalibrated-window WARN

- Task ID: `2026-08-28_overflow-guard`
- Home Repo: `opencode-acp`
- Created: 2026-08-28
- Status: Accepted

## 1. Problem Statement

- **What problem are we solving?** When a model reports `limit.context = 0`, ACP's
`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.
- **Why now?** Reported in #347 with a concrete, reproducible production failure
(sglang qwen3.8-27b, real window 262,144). It affects *every* custom provider
without a catalog entry.

## 2. Goals & Non-Goals

- **Goals**:
- Make the uncalibrated-window blindness *visible* (one-time WARN per session).
- Add a *request-side hard guard* that deterministically keeps the outgoing
request within the known window, independent of model cooperation.
- **Non-Goals**:
- Fixing opencode's exit-0-on-400 / maxTokens bugs (upstream).
- Learning the window from 400s (blocked — no response-error hook for plugins).

## 3. Current Architecture

- `createSystemPromptHandler` (hooks.ts) is the only writer of
`state.modelContextLimit`; it guards on `input.model?.limit?.context`, so a model
reporting `0` never sets it. The `#312` catalog reconciliation in
`createChatMessageTransformHandler` also misses (catalog drops `limit <= 0`).
- All percentage consumers (`parseLimitValue` in `inject/utils.ts`) return
`undefined` when `modelContextLimit` is `undefined`.
- `truncateLargeToolOutputs` (truncate-tools.ts) is the only existing space-freer,
but it returns early `if (!state.modelContextLimit)` — i.e. it is *also* blind to
the exact case we care about.

## 4. Proposed Architecture

```
messages.transform (createChatMessageTransformHandler)
├─ reconcile modelContextLimit from catalog (#312)
├─ updatePerTurnState
├─ trackUncalibratedWindow(state, logger) [FIX 1 — new]
│ └─ if modelContextLimit undefined N turns → one-time WARN
├─ prune → truncateLargeToolOutputs
├─ pruneToFit(state, config, logger, messages) [FIX 2 — new]
│ ├─ knownWindow = resolveKnownWindow(...)
│ │ = modelContextLimit ?? abs modelMaxLimits[p/m] ?? abs maxContextLimit
│ ├─ safeBudget = knownWindow - overflowGuardReserve
│ ├─ estimate = getCurrentTokenUsage + last-asst trailing tool outputs (B1)
│ │ + msgs after last assistant (N2) + WIRE_SAFETY_MARGIN (O(1))
│ │ [precise content count if no provider usage, or if the last
│ │ assistant step ran a compress — stale base (N1)]
│ └─ if estimate > safeBudget: clear oldest non-protected tool outputs
│ (skip protected tools/paths, current turn, user msgs, recent zone)
│ until estimate - freed <= safeBudget
└─ ... (nudge injection, id injection, etc.)
```

- **Key components**:
- `pruneToFit` / `resolveKnownWindow` (`lib/messages/prune-to-fit.ts`).
- `trackUncalibratedWindow` (`lib/messages/uncalibrated-window.ts`).
- **Data flow**: The guard mutates tool parts' `state.output` in place (same
mechanism as `truncateLargeToolOutputs`), so the change applies to the outgoing
request. It is idempotent (already-cleared outputs are skipped).
- **API / interface changes**: Two new config knobs; three new *transient* (non-
persisted) `SessionState` fields; two new exported functions.

## 5. Design Decisions & Rationale

| Decision | Options Considered | Chosen | Why |
|----------|--------------------|--------|-----|
| Where to guard | (a) rely on nudges (model-driven); (b) request-side hard guard | (b) | Nudges are advisory and the model may not comply; a hard 400 needs a deterministic, model-independent fix. |
| `knownWindow` source | (a) `modelContextLimit` only; (b) also absolute `maxContextLimit` | (b) | Lets the guard protect users who declare an absolute budget even when the model reports no window. Percent values are *not* used as the window (they'd be the nudge threshold, not the real window → massive over-prune). |
| Completion reserve | (a) store `limit.output` in state; (b) fixed config knob | (b) `overflowGuardReserve` (default 32768) | Avoids state churn + model-switch staleness; 32768 covers opencode's 32000 fallback for `limit.output = 0`. User can tune down for small-output models. |
| Wire-size estimate | (a) always precise count; (b) O(1) provider usage + trailing tool outputs + after-last-assistant msgs + margin, precise only as fallback | (b) | The precise count is O(total tokens); running it every well-under-budget turn is wasteful. `getCurrentTokenUsage` is O(1) but reports the context size *after* the last LLM call — it omits (i) tool outputs appended *after* that call (opencode runs messages.transform on every LLM call, so a mid-turn sub-request carries fresh tool outputs) and (ii) any messages after the last assistant (the current user turn). We add both to close the gap (review findings B1, N2). The trailing-run count is exact for both text and tool-calls-only steps (a step's own usage cannot include its own tool results). `WIRE_SAFETY_MARGIN = 8192` covers nudges/ID tags appended after the guard. The precise count is also used when the last assistant step ran a `compress` — the provider usage is then stale (still includes the range `prune()` removes), so counting the already-pruned content avoids over-clearing (review finding N1). |
| What to free | (a) truncate (prefix+suffix); (b) clear entirely | (b) | In an overflow emergency, freeing maximum space is the priority; the model can re-run the tool. `truncateLargeToolOutputs` already handles the gentler truncation at the GC threshold. |
| WARN mechanism | (a) inline in handler; (b) extracted pure fn | (b) `trackUncalibratedWindow` | Testable in isolation; keeps the handler lean. Threshold 3 rules out the first-request race (system.transform runs after messages.transform). |

## 6. Impact Analysis

- **Backward compatibility**: Additive only. Two new config knobs (sensible defaults),
three new transient state fields (not persisted — old state files load fine; they
default to `0`/`false`), two new exports. No persisted-format or internal-tag change.
- **Performance**: No-op (O(1) check) when under budget. Precise tokenization only on
the first turn without provider token data.
- **Security**: None.
- **Dependencies**: None new.

## 7. Migration Plan

- **Steps**:
1) Ship with `overflowGuard: true` by default.
2) Users on custom providers see the one-time WARN and are told exactly what to
configure (`limit` in opencode.json or absolute `compress.maxContextLimit`).
- **Feature flags / gradual rollout**: `compress.overflowGuard: false` disables the
guard entirely (the WARN still fires, which is desirable).

## 8. Open Questions

- [ ] Should the guard also truncate (not just clear) as an intermediate step before
clearing entirely? (Deferred — clearing is the effective last resort; the
existing `truncateLargeToolOutputs` covers the gentler case.)
- [ ] Once opencode exposes a response-error hook, add "learn the window from 400s"
(issue Fix 3) to make the guard work with zero configuration.
98 changes: 98 additions & 0 deletions devlog/2026-08-28_overflow-guard/REQ.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# REQ - Request-side overflow guard + uncalibrated-window WARN

- Task ID: `2026-08-28_overflow-guard`
- Home Repo: `opencode-acp`
- Created: 2026-08-28
- Status: InProgress
- Priority: P1
- Owner: ranxianglei
- References: https://github.com/ranxianglei/opencode-acp/issues/347

## 1. Background & Problem Statement

- **Context**: ACP's percentage thresholds (`minContextLimit`, `maxContextLimit`,
`emergencyThresholdPercent`) are only as good as `state.modelContextLimit`. For a
custom OpenAI-compatible provider that reports `limit.context = 0`, the model-limit
catalog never records a window (`record()` drops `limit <= 0`) and the system hook
never sets `modelContextLimit` (it guards on `limit.context`). Every percentage
threshold then resolves to `undefined` and silently no-ops.
- **Current behavior (symptom)**: A long headless session grows past the backend's
real window (262,144 for the reporter's sglang qwen3.8-27b) and dies on every
resume with a `400 Bad Request` ("Requested token count exceeds the model's maximum
context length of 262144 tokens") that opencode swallows — exit 0, no output.
Deterministic and unrecoverable for that session id. No error is ever surfaced.
- **Expected behavior**: (a) The blindness is *visible* — a prominent one-time WARN
tells the user their model reports no window and what to configure. (b) The request
is *protected* — even without model cooperation, ACP deterministically keeps the
outgoing request within the known window so a 400 becomes a degraded-but-working
turn instead of a silent death loop.
- **Impact**: Any custom provider without a catalog entry has all percentage
protection silently disabled. The failure mode is the worst kind: silent (exit 0),
deterministic, and unrecoverable for the affected session.

## 2. Reproduction (if applicable)

- **Environment**:
- Node: 22
- OS/Arch: linux
- opencode 1.14.46, plugin opencode-acp@latest, model `vllm-qwen/qwen3.8-27b`
declared in opencode.json **without** a `limit` (so `limit.context = 0`), backend
sglang real window 262,144, `compaction.auto: false`, per-message resume.
- **Minimal reproduction steps**:
1) Run a long headless session against a provider that reports `limit.context = 0`.
2) Let the context exceed the backend's real window.
3) Resume the session → 400 → opencode exits 0 with no output, every time.
- **Relevant configuration**: no `limit` in opencode.json; default percentage
thresholds in acp.jsonc (or none).

## 3. Constraints & Non-Goals

- **Constraints**:
- Backward compatibility: no change to persisted state format beyond two new
*transient* (non-persisted) fields; no change to internal `dcp` tags.
- Performance: the guard's precise token count must not run on every well-under
budget turn (use the O(1) provider-reported usage as the primary estimate).
- The guard must never clear protected tools / protected file paths (Bug 39 parity).
- **Non-Goals** (explicitly out of scope):
- Fixing opencode's `exit 0 on 400` and `options.maxTokens not honored` bugs
(upstream opencode issues, filed separately).
- "Learn the window from 400s" (issue Fix 3) — **blocked**: opencode exposes no
response-error hook to plugins, so a plugin cannot observe the 400.

## 4. Acceptance Criteria (must be testable)

- **Correctness**:
- [x] When `modelContextLimit` stays undefined across ≥ 3 transforms, a one-time
WARN is logged per session (deduped), and the counter resets once a window
resolves.
- [x] When the estimated wire size exceeds `knownWindow - overflowGuardReserve`,
the oldest compressible (non-protected) tool outputs are cleared until the
estimate fits; the guard stops as soon as it fits and never touches the
current turn, user messages, or the recent-message protection zone.
- [x] `knownWindow` = `modelContextLimit` when set, else absolute
`compress.modelMaxLimits[provider/model]`, else absolute
`compress.maxContextLimit`; `undefined` (guard off) when only a percent is
configured and no window is known.
- **Performance / Stability**:
- [x] The guard is a no-op (no precise tokenization) when the O(1) estimate is
under budget.
- **Regression**:
- [x] New/modified test cases added to test suite and passing
(`tests/prune-to-fit.test.ts`, 25 tests).

## 5. Proposed Approach (optional)

- **Affected modules & entry files**:
- `lib/messages/prune-to-fit.ts` (new) — `pruneToFit` + `resolveKnownWindow`.
- `lib/messages/uncalibrated-window.ts` (new) — `trackUncalibratedWindow`.
- `lib/hooks.ts` — call both in the message-transform pipeline.
- `lib/config.ts`, `lib/config-validation.ts`, `dcp.schema.json` — new knobs
`compress.overflowGuard` (bool, default true) + `compress.overflowGuardReserve`
(number, default 32768).
- `lib/state/types.ts`, `lib/state/state.ts` — two transient fields.
- `lib/messages/index.ts` — barrel exports.
- **Risks**: Over-pruning when the user sets `maxContextLimit` well below the real
window (documented; the user controls the declared budget). Clearing tool outputs
loses that output until the tool is re-run (intentional, last-resort).
- **Rollback strategy**: Set `compress.overflowGuard: false` to disable the guard;
the WARN is harmless. Full rollback = revert the branch.
Loading
Loading