From efdaecacc2382ea38111fccad6a33b01272f6b7d Mon Sep 17 00:00:00 2001 From: ework-agent Date: Sat, 29 Aug 2026 00:40:53 +0800 Subject: [PATCH 1/2] fix: context-limit safety net for spawn+resume mode (#346) 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. --- CONFIGURATION.md | 6 + CONFIGURATION.zh-CN.md | 6 + dcp.schema.json | 6 + .../DESIGN.md | 126 ++++++++++ .../REQ.md | 144 ++++++++++++ .../WORKLOG.md | 157 +++++++++++++ lib/compress/decompress.ts | 13 +- lib/config-validation.ts | 23 ++ lib/config.ts | 9 + lib/gc/merge.ts | 11 +- lib/hooks.ts | 87 +++++-- lib/messages/inject/utils.ts | 20 +- lib/messages/truncate-tools.ts | 33 ++- lib/state/state.ts | 24 ++ lib/state/utils.ts | 30 +++ tests/context-limit-fallback.test.ts | 220 ++++++++++++++++++ tests/model-switch-limits.test.ts | 220 +++++++++++++++++- tests/registry-stub.ts | 14 ++ tests/truncate-tools.test.ts | 110 ++++++++- 19 files changed, 1222 insertions(+), 37 deletions(-) create mode 100644 devlog/2026-08-28_spawn-resume-context-limit/DESIGN.md create mode 100644 devlog/2026-08-28_spawn-resume-context-limit/REQ.md create mode 100644 devlog/2026-08-28_spawn-resume-context-limit/WORKLOG.md create mode 100644 tests/context-limit-fallback.test.ts diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 5155b345..e7eca3f6 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -190,6 +190,12 @@ Core compression behavior. - **Status:** DEPRECATED - **Description:** **Deprecated — scheduled for removal alongside `minContextLimit`.** Per-model override for `minContextLimit`. Still honored until removed. +#### `compress.contextLimitFallback` +- **Type:** `number` +- **Default:** `128000` +- **Status:** ACTIVE +- **Description:** Fallback context window (absolute tokens) used when the model's limit is unknown — e.g. custom providers with no declared limit, or headless spawn+resume sessions where the limit was never learned. Drives all percentage thresholds (`maxContextLimit`/`minContextLimit`), the emergency nudge override, batch cleanup GC, and in-flight tool-output truncation. The real model limit always takes precedence when known. Set to `0` to disable the fallback (legacy behavior: no safety net until the limit is learned). + #### `compress.nudgeFrequency` - **Type:** `number` - **Default:** `5` diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index d5bac1f1..f0f5ef18 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -190,6 +190,12 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): - **状态:** DEPRECATED - **说明:** **已废弃——将与 `minContextLimit` 一同移除。** 按模型覆盖 `minContextLimit`。在此之前仍然生效。 +#### `compress.contextLimitFallback` +- **类型:** `number` +- **默认值:** `128000` +- **状态:** ACTIVE +- **说明:** 当模型上下文窗口未知时使用的回退窗口(绝对 token 数)——例如未声明 limit 的自定义 provider,或从未学到 limit 的 headless spawn+resume 会话。驱动所有百分比阈值(`maxContextLimit`/`minContextLimit`)、紧急 nudge 覆盖、批量清理 GC 与 in-flight 工具输出截断。已知真实模型 limit 时始终优先。设为 `0` 可禁用回退(旧行为:学到 limit 前无安全网)。 + #### `compress.nudgeFrequency` - **类型:** `number` - **默认值:** `5` diff --git a/dcp.schema.json b/dcp.schema.json index 0eea039f..4db69c6b 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -192,6 +192,12 @@ ] } }, + "contextLimitFallback": { + "description": "Fallback context window (absolute tokens) used when the model's limit is unknown (e.g. custom providers with no declared limit). Drives nudge thresholds, emergency override, GC, and in-flight truncation. Set to 0 to disable the fallback (legacy behavior: no safety net until the limit is learned).", + "type": "number", + "default": 128000, + "minimum": 0 + }, "nudgeFrequency": { "type": "number", "default": 5, diff --git a/devlog/2026-08-28_spawn-resume-context-limit/DESIGN.md b/devlog/2026-08-28_spawn-resume-context-limit/DESIGN.md new file mode 100644 index 00000000..3ce42ea9 --- /dev/null +++ b/devlog/2026-08-28_spawn-resume-context-limit/DESIGN.md @@ -0,0 +1,126 @@ +# DESIGN - Context-limit safety net for spawn+resume mode (issue #346) + +- Task ID: `2026-08-28_spawn-resume-context-limit` +- Home Repo: `opencode-acp` +- Created: 2026-08-28 +- Status: Accepted + +## 1. Problem Statement + +- **What problem are we solving?** + ACP's safety net (nudge anchors, emergency override, batch-cleanup GC, + in-flight tool-output truncation) is gated on `state.modelContextLimit`. In + headless per-message spawn+resume mode the limit was never known when the + messages-transform pipeline ran, so every percentage threshold resolved to + `undefined` and the safety net was silently disabled. Sessions grew to the + serving wall (~229K tokens on a 262144 window + ~17K system + 16K + `max_tokens`) and entered an infinite empty-response retry loop (exit 0, no + error). +- **Why now?** Two production sessions froze at the wall with plugin logs + proving `postTokens == prePruneTokens` and `nudged=false` at every size. + +## 2. Goals & Non-Goals + +- **Goals**: + - The limit is known on the first request of a spawned process. + - A configurable fallback window bounds the conversation when the limit is + genuinely unknown. + - In-flight truncation fires before the serving wall (overhead-aware). + - A loud ERROR when the post-transform context still exceeds the budget. +- **Non-Goals**: + - Changing opencode-core's exit-0 empty response (upstream issue). + - Persisting the model's max-output-tokens limit (constant reserve + + `gc.majorGcThresholdPercent` escape hatch instead). + - Changing the messages-transform pipeline order. + +## 3. Current Architecture (if applicable) + +- **How it works today**: + - `state.modelContextLimit` is written only by the system-prompt hook + (`lib/hooks.ts`), which runs AFTER the messages-transform within one + request — and it is never persisted (saves happen inside the + messages-transform pipeline, before the system hook of the same request). + - The model-limit catalog (`lib/state/model-limits.ts`) is seeded at plugin + init via a fire-and-forget `client.config.providers()` call that races + server readiness in spawned processes. + - `resolveContextTokenLimit` (`lib/messages/inject/utils.ts`) returns + `undefined` for percentage thresholds when `state.modelContextLimit` is + `undefined`; `isContextOverLimits` then reports no limit crossed; anchor + sets stay empty; `runBatchCleanup` and `truncateLargeToolOutputs` early + return. +- **Pain points**: limit learned and lost per request; no retry on init-time + hydration failure; truncation threshold at 100% of the window ignores the + system prompt + `max_tokens` overhead. + +## 4. Proposed Architecture + +- **Overview**: + ``` + system.transform (per request) + └─ learn limit → if changed: saveSessionState (NEW) + messages.transform (per request) + ├─ catalog resolve (existing #312 reconciliation) + │ └─ miss → registry.hydrateAndResolve: ONE lazy hydration/process (NEW) + ├─ effective limit = model limit ?? compress.contextLimitFallback (NEW helper) + ├─ nudge thresholds / emergency override / GC / truncation use effective limit + └─ post-transform: if postTokens > limit − systemPromptTokens − 16384 + → logger.error("ACP hard guard: ...") (NEW) + ``` +- **Key components**: + - `SessionStateRegistry.hydrateAndResolve(client, providerId, modelId)` — + resolve; on miss, hydrate from the client once per process, re-resolve. + - `resolveEffectiveContextLimit(state, config): {limit, source: +"model"|"fallback"} | undefined` — single source of truth for the window. + - `compress.contextLimitFallback` (default 128000, `0` disables). + - `OUTPUT_RESERVE_TOKENS = 16384` (`lib/messages/truncate-tools.ts`). +- **Data flow**: + - Limit lifecycle: system hook → state file (persisted on change) → next + process's `ensureSessionInitialized` (already restored) → threshold math. + Fallback chain: persisted/learned model limit → catalog (init seed or + lazy hydration) → `contextLimitFallback` → `undefined` (legacy no-op). +- **API / interface changes**: + - New config key `compress.contextLimitFallback` (validated, schema'd, + documented). + - New registry method `hydrateAndResolve`. + - New export `resolveEffectiveContextLimit` + `EffectiveContextLimit`. + - New export `OUTPUT_RESERVE_TOKENS`. + - Persisted state shape: unchanged (fields already existed). + +## 5. Design Decisions & Rationale + +| Decision | Options Considered | Chosen | Why | +| --------------------------- | ------------------------------------------------------------------------------------ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Where to persist the limit | (a) system hook saves on change; (b) messages transform re-derives from catalog only | (a) | The system hook is the only place the host tells us the real limit; saving on change is cheap (one file write per model/limit change, not per request). | +| Init-time hydration retry | (a) retry loop at init; (b) lazy one-shot hydration during a request | (b) | During a request the server is guaranteed up (we are inside its pipeline); one retry per process avoids repeated HTTP calls; the fallback covers the still-unknown case. | +| Unknown-limit behavior | (a) keep legacy no-op; (b) new fallback key, default on | (b) with `0` escape hatch | The issue's core complaint is "the safety net never engages"; a conservative 128K default bounds sessions while `0` restores legacy behavior for anyone who relied on it. | +| Truncation threshold | (a) keep 100% of window; (b) `min(configured, limit − systemPromptTokens − 16384)` | (b) | The serving wall includes system prompt + tool schemas + `max_tokens`; 100% of the window is already past it (the production failure). `min()` keeps `gc.majorGcThresholdPercent` as the user escape hatch for larger outputs. | +| Overflow signaling | (a) throw/abort from the plugin; (b) loud ERROR log | (b) | Plugins cannot set the process exit code or reject the request; an ERROR in the daily log gives the operator/orchestrator a greppable signal. Exit-0 empty response is filed upstream. | +| Internal-agent limit writes | (a) write before signature check (status quo); (b) skip internal agents | (b) | Title/summary/compaction agents run on their own small model; their limit must not corrupt the session's real limit (would shrink every threshold). | + +## 6. Impact Analysis + +- **Backward compatibility**: + - State file: additive only (fields pre-existed). + - Config: new key defaults on; `contextLimitFallback: 0` restores the exact + legacy behavior. + - Sessions with a known model limit: unchanged (model limit always + precedence). + - Sessions with an unknown limit: previously unbounded, now bounded by the + fallback window (intended behavior change). +- **Performance**: at most one extra `client.config.providers()` call per + process (and only on a catalog miss); one extra state save per limit change. + No new per-request work on the common path. +- **Security**: none (no new external input handling). +- **Dependencies**: none. + +## 7. Migration Plan (if applicable) + +- **Steps**: + 1. Ship with the fallback default ON (128000). + 2. Operators of very large-window custom providers who want the legacy + "no safety net until learned" behavior set + `"compress": { "contextLimitFallback": 0 }`. + 3. Operators whose `max_tokens` exceeds 16K lower + `gc.majorGcThresholdPercent` (e.g. `"85%"`). +- **Feature flags / gradual rollout**: `contextLimitFallback: 0` is the + rollback switch; no flag needed beyond it. diff --git a/devlog/2026-08-28_spawn-resume-context-limit/REQ.md b/devlog/2026-08-28_spawn-resume-context-limit/REQ.md new file mode 100644 index 00000000..990f8c50 --- /dev/null +++ b/devlog/2026-08-28_spawn-resume-context-limit/REQ.md @@ -0,0 +1,144 @@ +# REQ - Context-limit safety net never engages in spawn+resume mode (issue #346) + +- Task ID: `2026-08-28_spawn-resume-context-limit` +- Home Repo: `opencode-acp` +- Created: 2026-08-28 +- Status: InProgress +- Priority: P0 +- Owner: ranxianglei +- References: [issue #346](https://github.com/ranxianglei/opencode-acp/issues/346), [issue #312](https://github.com/ranxianglei/opencode-acp/issues/312) (preceding fix: model-limit catalog) + +## 1. Background & Problem Statement + +- **Context**: ACP's entire safety net (nudge anchors, emergency override, + in-flight tool-output truncation, batch cleanup GC) is gated on + `state.modelContextLimit`. Every percentage threshold + (`compress.maxContextLimit`/`minContextLimit` = "80%", + `compress.emergencyThresholdPercent` = "98%", + `gc.majorGcThresholdPercent` = "100%") resolves to `undefined` when the limit + is unknown, and every consumer treats `undefined` as "do nothing". +- **Root cause** (proven from two production sessions, plugin daily logs): + in headless per-message spawn+resume mode (orchestrator spawns a fresh + opencode process per message, resumes by session ID) the limit is never + known at the time the messages-transform pipeline runs: + 1. Within one request the host fires `messages.transform` BEFORE + `system.transform` (sst/opencode, confirmed in #312). The only writer of + `state.modelContextLimit` is the system hook — so on the (only) request a + spawned process handles, the limit is still `undefined` during all + threshold math. + 2. The system hook never persists the limit it learns (`saveSessionState` + only runs inside the messages-transform pipeline), so the next spawned + process starts from `modelContextLimit: undefined` again. The limit is + learned and lost, every message, forever. + 3. The catalog seed at plugin init (`hydrateModelLimitsFromClient`, + fire-and-forget) races server readiness: the provider-config HTTP call + can fail before the server is up, and nothing retries. +- **Current behavior (symptom)**: at 229,479 / 229,535 tokens (sglang qwen 27B, + `max_model_len: 262144`) every transform logs + `prePruneTokens == postTokens`, `nudged=false`; proactive compaction never + fired (`lastCompaction: 0`). Total request size (context + ~15-20K system & + tool schema + `max_tokens: 16384`) exceeds the serving window → immediate + length rejection → opencode exits 0 with zero output → orchestrator retries + the identical failing request forever. +- **Expected behavior**: + 1. The model context limit is known on the first request of a spawned + process (persisted from the previous request's system hook, or lazily + hydrated from the server during the request — the server is guaranteed up + by then). + 2. When the limit is genuinely unknown, a configurable fallback limit bounds + the conversation instead of disabling the safety net. + 3. In-flight tool-output truncation fires before the request hits the + serving wall (limit minus system-prompt + output-token overhead), not at + 100% of the window. + 4. When the post-transform context still exceeds the model budget, ACP logs + an ERROR (the exit-0 empty response is opencode-core behavior; the plugin + cannot set the exit code). +- **Impact**: any headless spawn+resume deployment with a custom/direct + provider whose limit is not resolvable from the init-time catalog is + unbounded: guaranteed infinite retry loop at the length-rejection wall, no + user-visible error. + +## 2. Reproduction + +- **Environment**: opencode 1.14.46 + opencode-acp v1.14.25, direct-to-sglang + (qwen 27B, `max_model_len: 262144`, `max_tokens: 16384`), headless + per-message spawn+resume, `acp.jsonc` containing only the `$schema` ref + (all defaults). +- **Minimal reproduction steps**: + 1. Spawn opencode per message, resume by session ID; grow the conversation + past 80% of the model window (or past the fallback limit). + 2. Observe `Chat transform complete` logs: `postTokens == prePruneTokens`, + `nudged=false` at every size; no truncation, no batch cleanup. + 3. At ~229K tokens the request exceeds `max_model_len` → rejected → empty + run, exit 0 → retry loop. +- **Relevant configuration**: all defaults. `gc.majorGcThresholdPercent: +"100%"` means in-flight truncation only starts at the full window — already + past the serving wall once system prompt + tool schema + `max_tokens` are + added. + +## 3. Constraints & Non-Goals + +- **Constraints**: + - Backward compatibility: persisted state shape is additive only + (modelContextLimit/identity already persisted); new config key + `compress.contextLimitFallback` defaults to on; `0` restores legacy + behavior. Internal `dcp` naming untouched. + - No new dependencies. No `any`. Tests pass under + `node --import tsx --test tests/*.test.ts`. + - The messages-transform pipeline order is unchanged. +- **Non-Goals**: + - Fixing opencode-core's exit-0 empty response (upstream issue candidate, + reported separately). + - Time-based nudge cadence / emergency-notification cadence changes. + - Persisting the model's max-output-tokens limit (the 16K reserve constant + covers the reported deployment; users with larger `max_tokens` can lower + `gc.majorGcThresholdPercent`). + +## 4. Acceptance Criteria + +- **Correctness**: + - [ ] The system hook persists `modelContextLimit` + model identity on + change, so a freshly spawned process resumes with the limit known. + - [ ] Internal-agent requests (title/summary/compaction) no longer overwrite + the session limit with a different model's limit. + - [ ] On a catalog miss during messages.transform, the catalog is hydrated + once lazily (server is up during a request) before threshold math. + - [ ] With an unknown limit, `compress.contextLimitFallback` (default 128000) drives nudge thresholds, emergency override, batch cleanup, + and in-flight truncation; `0` disables the fallback. + - [ ] In-flight truncation threshold = `min(gc.majorGcThresholdPercent × + limit, limit − systemPromptTokens − 16384)`; production numbers + (limit 262144, system ~17K, 229479 tokens) now trigger truncation. + - [ ] Post-transform tokens above the model budget log an ERROR with the + budget breakdown. +- **Performance / Stability**: + - [ ] No new per-request HTTP calls on the common path (lazy hydration at + most once per process; persistence only on change). +- **Regression**: + - [ ] New tests cover: limit persistence, lazy hydration, fallback + thresholds, overhead-aware truncation (production repro), hard-guard + error log. Each verified to FAIL against the pre-fix code. + - [ ] Full suite passes. + +## 5. Proposed Approach + +- **Affected modules & entry files**: + - `lib/hooks.ts` — system hook: move limit write after internal-agent + check, persist on change; messages transform: lazy hydration, effective + limit in filter ctx + logs, hard-guard ERROR. + - `lib/state/state.ts` — `SessionStateRegistry.hydrateAndResolve()` (lazy + one-shot hydration). + - `lib/state/utils.ts` — `resolveEffectiveContextLimit(state, config)` + helper (`model` | `fallback` source). + - `lib/config.ts` + `lib/config-validation.ts` + `dcp.schema.json` — new + `compress.contextLimitFallback` (number, default 128000, 0 = off). + - `lib/messages/inject/utils.ts` — `resolveContextTokenLimit` / + `isContextOverLimits` use the effective limit. + - `lib/gc/merge.ts` — `runBatchCleanup` uses the effective limit. + - `lib/messages/truncate-tools.ts` — `OUTPUT_RESERVE_TOKENS = 16384`, + overhead-aware threshold. + - Docs: CONFIGURATION.md (+ zh-CN), devlog. +- **Risks**: fallback default (128K) makes unknown-limit sessions compress + earlier than "never" — intended; disable with `contextLimitFallback: 0`. + Persisting the limit adds one small write on model/limit change only. +- **Rollback strategy**: revert the PR; state files remain compatible (new + fields are ignored by older versions). diff --git a/devlog/2026-08-28_spawn-resume-context-limit/WORKLOG.md b/devlog/2026-08-28_spawn-resume-context-limit/WORKLOG.md new file mode 100644 index 00000000..ed834b5b --- /dev/null +++ b/devlog/2026-08-28_spawn-resume-context-limit/WORKLOG.md @@ -0,0 +1,157 @@ +# WORKLOG - Context-limit safety net never engages in spawn+resume mode (issue #346) + +- Task ID: `2026-08-28_spawn-resume-context-limit` +- Home Repo: `opencode-acp` +- Status: InProgress +- Updated: 2026-08-28 + +## 1. Summary + +- **What was done** (1–3 sentences): + Made the model context limit survive headless spawn+resume (persist it from + the system hook, lazily hydrate the catalog during a request), added a + configurable fallback window (`compress.contextLimitFallback`, default 128000) so the safety net works even when the limit is genuinely unknown, + made in-flight tool-output truncation overhead-aware (window − system + prompt − 16K output reserve), and added a loud ERROR log ("ACP hard guard") + when the post-transform context still exceeds the model budget. +- **Why** (1–3 sentences): + In spawn+resume mode the limit was learned and lost on every request, so + every percentage threshold resolved to `undefined` and the entire safety net + (nudges, GC, in-flight truncation) was silently disabled. Production sessions + grew to the length-rejection wall (~229K tokens on a 262144 window) and + entered an infinite empty-response retry loop with no error surfaced. +- **Behavior / compatibility changes**: Yes — + - New config key `compress.contextLimitFallback` (default 128000; `0` = + legacy behavior). + - The system hook now persists `modelContextLimit` + model identity on + change (state file shape is additive; fields already existed in the + persisted schema). + - In-flight truncation starts earlier: at + `min(gc.majorGcThresholdPercent × limit, limit − systemPromptTokens − 16384)` + instead of 100% of the window. + - New ERROR log line when post-transform tokens exceed the budget. + - Internal-agent (title/summary/compaction) system prompts no longer + overwrite the session limit. +- **Risk level**: Medium (threshold behavior changes for unknown-limit + sessions — previously "no safety net", now "safety net against 128K or the + configured fallback"; disable with `contextLimitFallback: 0`). + +## 2. Change Log + +### Commits + +| Commit | Description | +| ------- | ---------------------------------------------------------- | +| `` | fix: context-limit safety net for spawn+resume mode (#346) | + +### Key Files + +- `lib/hooks.ts` — system hook persists limit+identity on change (moved after + the internal-agent signature check); messages transform lazily hydrates the + catalog on a catalog miss (`registry.hydrateAndResolve`); transform log and + new "ACP hard guard" ERROR use the effective limit; `OUTPUT_RESERVE_TOKENS` + imported from truncate-tools. +- `lib/state/state.ts` — `SessionStateRegistry.hydrateAndResolve(client, +providerId, modelId)`: resolve → one lazy hydration per process on miss → + re-resolve. +- `lib/state/utils.ts` — `resolveEffectiveContextLimit(state, config)` + + `EffectiveContextLimit` type: model limit if known, else + `compress.contextLimitFallback` if > 0, else `undefined`. +- `lib/config.ts` — `CompressConfig.contextLimitFallback?: number`, default + 128000, merged in `mergeCompress`. +- `lib/config-validation.ts` — `compress.contextLimitFallback` in + `VALID_CONFIG_KEYS` + number/non-negative type validation. +- `dcp.schema.json` — `contextLimitFallback` schema entry. +- `lib/messages/inject/utils.ts` — `resolveContextTokenLimit` resolves + percentage thresholds against the effective limit; `isContextOverLimits` + reports the effective limit to downstream consumers (emergency override, + displays, block guidance). +- `lib/messages/truncate-tools.ts` — `OUTPUT_RESERVE_TOKENS = 16384`; + effective-limit gating; overhead-aware threshold; ERROR + bail when the + window cannot fit the overhead. +- `lib/gc/merge.ts` — `runBatchCleanup` uses the effective limit. +- `lib/compress/decompress.ts` — context-usage displays use the effective + limit. +- `CONFIGURATION.md` / `CONFIGURATION.zh-CN.md` — documented + `compress.contextLimitFallback`. +- `tests/model-switch-limits.test.ts` — 8 new tests (lazy hydration, + persistence, internal-agent guard, hydrateAndResolve ×3, hard guard ×2); + `runTransform` harness extended with `client`/`logger`/`config` options. +- `tests/truncate-tools.test.ts` — 3 new tests (production wall repro with + the exact production numbers, overhead bail, fallback-driven truncation); + 2 pre-existing tests moved to a realistic 200K window (the old 1000-token + window can no longer fit the 16K output reserve — by design). +- `tests/context-limit-fallback.test.ts` — new file: 9 tests for + `resolveEffectiveContextLimit` and `isContextOverLimits` fallback behavior. +- `tests/registry-stub.ts` — `createTestRegistry` gained `hydrateAndResolve` + mirroring the real registry. + +## 3. Design & Implementation Notes + +- **Entry point / key function**: `resolveEffectiveContextLimit` + (`lib/state/utils.ts`) is the single source of truth for "which window does + the safety net operate against"; every consumer (nudge thresholds, emergency + override, GC, truncation, displays) goes through it. +- **Key configuration items**: `compress.contextLimitFallback` (default + 128000, `0` disables); `gc.majorGcThresholdPercent` (user escape hatch for + larger `max_tokens` — the `min()` keeps the stricter bound). +- **Key logic explanation**: + - Limit lifecycle: system hook learns the limit → persists on change → + next spawned process loads it from the state file. If still unknown + (first session, custom provider), the messages transform hydrates the + catalog once per process from `client.config.providers()` (server is + guaranteed up during a request). If still unknown, the fallback window + applies. + - Serving wall: a request fits only if `conversation + systemPromptTokens + +max_tokens ≤ max_model_len`. Truncation therefore starts at + `min(configured threshold, limit − systemPromptTokens − 16384)`; if that + is ≤ 0 the window is unusable and ACP logs an ERROR instead of + truncating. + - Hard guard: after the full transform pipeline, if `postTokens > +limit − systemPromptTokens − 16384`, ACP logs an ERROR with the budget + breakdown. The exit-0 empty response itself is opencode-core behavior and + cannot be changed from a plugin. + +## 4. Testing & Verification + +### Build & Test Commands + +```sh +# Build +cd opencode-acp && npm run build + +# Run full test suite +node --import tsx --test tests/*.test.ts + +# Run specific test file +node --import tsx --test tests/.test.ts + +# Type check +npx tsc --noEmit +``` + +### Test Coverage + +- New/modified test files: `tests/context-limit-fallback.test.ts` (new), + `tests/model-switch-limits.test.ts`, `tests/truncate-tools.test.ts`, + `tests/registry-stub.ts` (helper). +- Test count: 1049 total, 1049 pass, 0 fail (baseline 1029 before this + change). +- Key scenarios verified: + - **Pre-fix failure check (mandatory)**: with the source changes stashed, + all 11 behavioral tests fail (lazy hydration, persistence, + internal-agent guard, hydrateAndResolve ×3, hard guard, production wall + repro, overhead bail, fallback truncation, fallback file import) — + re-applying the fixes turns them green. + - **Production repro**: limit 262144, systemPromptTokens 17000, + currentTokens 229479 (the exact production token count) → truncation now + fires (threshold 228760); pre-fix it was a no-op (229479 < 262144). + - **Compatibility**: all 10 pre-existing #312 model-switch tests pass + unchanged (their configs carry no `contextLimitFallback`, preserving the + legacy invalidation semantics). + +### Results + +- `npm run typecheck`: pass +- `npm run test`: 1049/1049 pass +- `npm run build`: pass diff --git a/lib/compress/decompress.ts b/lib/compress/decompress.ts index 0e2576a4..39945575 100644 --- a/lib/compress/decompress.ts +++ b/lib/compress/decompress.ts @@ -8,6 +8,7 @@ import { saveSessionState } from "../state/persistence" import { assignMessageRefs } from "../message-ids" import { syncCompressionBlocks } from "../messages" import { getCurrentTokenUsage } from "../token-utils" +import { resolveEffectiveContextLimit } from "../state/utils" import { fetchSessionMessages, buildSearchContext, @@ -276,10 +277,10 @@ export function createDecompressTool(factoryCtx: ToolFactoryContext): ReturnType const ctx = resolveToolContext(factoryCtx, toolCtx.sessionID) const { rawMessages } = await prepareDecompressSession(ctx, toolCtx) - const contextUsageBefore = ctx.state.modelContextLimit + const effectiveLimitBefore = resolveEffectiveContextLimit(ctx.state, ctx.config) + const contextUsageBefore = effectiveLimitBefore ? Math.round( - (getCurrentTokenUsage(ctx.state, rawMessages) / - ctx.state.modelContextLimit) * + (getCurrentTokenUsage(ctx.state, rawMessages) / effectiveLimitBefore.limit) * 100, ) : undefined @@ -359,10 +360,10 @@ export function createDecompressTool(factoryCtx: ToolFactoryContext): ReturnType ctx.state.stats.totalPruneTokens - restoredTokens, ) - const contextUsageAfter = ctx.state.modelContextLimit + const effectiveLimitAfter = resolveEffectiveContextLimit(ctx.state, ctx.config) + const contextUsageAfter = effectiveLimitAfter ? Math.round( - (getCurrentTokenUsage(ctx.state, rawMessages) / - ctx.state.modelContextLimit) * + (getCurrentTokenUsage(ctx.state, rawMessages) / effectiveLimitAfter.limit) * 100, ) : undefined diff --git a/lib/config-validation.ts b/lib/config-validation.ts index 35d57a19..da10604c 100644 --- a/lib/config-validation.ts +++ b/lib/config-validation.ts @@ -30,6 +30,7 @@ export const VALID_CONFIG_KEYS = new Set([ "compress.modelMaxLimits", "compress.modelMinLimits", "compress.providers", + "compress.contextLimitFallback", "compress.nudgeFrequency", "compress.minNudgeContextPercent", "compress.nudgeGrowthTokens", @@ -878,6 +879,28 @@ export function validateConfigTypes(config: Record): ValidationErro validateProviderOverrides(compress.providers) + if ( + compress.contextLimitFallback !== undefined && + typeof compress.contextLimitFallback !== "number" + ) { + errors.push({ + key: "compress.contextLimitFallback", + expected: "number", + actual: typeof compress.contextLimitFallback, + }) + } + + if ( + typeof compress.contextLimitFallback === "number" && + compress.contextLimitFallback < 0 + ) { + errors.push({ + key: "compress.contextLimitFallback", + expected: "non-negative number (0 disables the fallback)", + actual: `${compress.contextLimitFallback}`, + }) + } + const validValues = ["ask", "allow", "deny"] if (compress.permission !== undefined && !validValues.includes(compress.permission)) { errors.push({ diff --git a/lib/config.ts b/lib/config.ts index e01c111c..a0c40add 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -63,6 +63,13 @@ export interface CompressConfig { modelMinLimits?: Record /** Nested per-provider / per-model overrides (billion-context-pi style). Resolved field-by-field: model > provider > global. */ providers?: Record + /** + * Fallback context window (absolute tokens) used when the model's limit is + * unknown (e.g. custom providers with no declared limit). Default: 128000. + * Set to 0 to disable the fallback (legacy behavior: no safety net until + * the limit is learned). + */ + contextLimitFallback?: number nudgeFrequency: number minNudgeContextPercent: number nudgeGrowthTokens?: number @@ -291,6 +298,7 @@ const defaultConfig: PluginConfig = { summaryBuffer: true, maxContextLimit: "80%", minContextLimit: "80%", + contextLimitFallback: 128000, nudgeFrequency: 5, minNudgeContextPercent: 5, iterationNudgeThreshold: 15, @@ -520,6 +528,7 @@ export function mergeCompress( modelMaxLimits: override.modelMaxLimits ?? base.modelMaxLimits, modelMinLimits: override.modelMinLimits ?? base.modelMinLimits, providers: mergeProviderOverrides(base.providers, override.providers), + contextLimitFallback: override.contextLimitFallback ?? base.contextLimitFallback, nudgeFrequency: override.nudgeFrequency ?? base.nudgeFrequency, minNudgeContextPercent: override.minNudgeContextPercent ?? base.minNudgeContextPercent, nudgeGrowthTokens: override.nudgeGrowthTokens, diff --git a/lib/gc/merge.ts b/lib/gc/merge.ts index 30207bfd..6e8aef17 100644 --- a/lib/gc/merge.ts +++ b/lib/gc/merge.ts @@ -2,6 +2,7 @@ import type { CompressionBlock, SessionState, WithParts } from "../state" import type { PluginConfig } from "../config" import type { Logger } from "../logger" import { countTokens, getCurrentTokenUsage } from "../token-utils" +import { resolveEffectiveContextLimit } from "../state/utils" import { COMPRESSED_BLOCK_HEADER, allocateBlockId, @@ -198,7 +199,10 @@ export function runBatchCleanup( savedTokens: 0, } - if (!state.modelContextLimit || state.modelContextLimit <= 0) { + // [FIX #346] Use the effective limit (model or fallback) so batch cleanup + // is not silently disabled when the model limit is unknown. + const effective = resolveEffectiveContextLimit(state, config) + if (!effective) { return noop } @@ -207,7 +211,7 @@ export function runBatchCleanup( // Only a hardcoded 100% force fallback remains. The mark_block mechanism and // the multi-tier (low/high/force) batch-cleanup were retired; full GC removal // is tracked separately. Threshold is intentionally NOT read from config. - if (currentTokens < state.modelContextLimit) { + if (currentTokens < effective.limit) { return noop } @@ -227,7 +231,8 @@ export function runBatchCleanup( mergedCount: result.mergedCount, savedTokens: result.savedTokens, currentTokens, - contextLimit: state.modelContextLimit, + contextLimit: effective.limit, + contextLimitSource: effective.source, }) return { diff --git a/lib/hooks.ts b/lib/hooks.ts index 9f84aabc..c01e5bf7 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -27,7 +27,8 @@ import { } from "./compress/timing" import { filterMessages, filterMessagesInPlace } from "./messages/shape" import { getLastUserMessage } from "./messages/query" -import { truncateLargeToolOutputs } from "./messages/truncate-tools" +import { OUTPUT_RESERVE_TOKENS, truncateLargeToolOutputs } from "./messages/truncate-tools" +import { resolveEffectiveContextLimit } from "./state/utils" import { handleContextCommand, handleStatsCommand, @@ -100,13 +101,6 @@ export function createSystemPromptHandler( // messages.transform creates the session state before this fires; if // absent (internal-agent early-return), there is nothing to attribute. const state = input.sessionID ? registry.get(input.sessionID) : undefined - if (state && input.model?.limit?.context) { - state.modelContextLimit = input.model.limit.context - // [FIX #312 follow-up] Record WHICH model the limit belongs to so - // the messages hook can detect staleness on a catalog miss. - state.modelProviderID = input.model?.providerID - state.modelID = input.model?.id - } if (!state || (state.isSubAgent && !config.allowSubAgents)) { return @@ -118,6 +112,32 @@ export function createSystemPromptHandler( return } + // [FIX #346] Attribute the limit to the session only for real session + // requests: internal agents (title/summary/compaction) may run on a + // different model and must not overwrite the session's limit. + // Persist on change so a freshly spawned process (headless + // spawn+resume) resumes with the limit already known — the system + // hook is the only writer and fires AFTER messages.transform within + // a request, so without this the limit is learned and lost every + // message and the safety net never engages. + if (input.model?.limit?.context) { + const limit = input.model.limit.context + const providerID = input.model?.providerID + const modelID = input.model?.id + const changed = + state.modelContextLimit !== limit || + state.modelProviderID !== providerID || + state.modelID !== modelID + state.modelContextLimit = limit + // [FIX #312 follow-up] Record WHICH model the limit belongs to so + // the messages hook can detect staleness on a catalog miss. + state.modelProviderID = providerID + state.modelID = modelID + if (changed) { + saveSessionState(state, logger).catch(() => {}) + } + } + const effectivePermission = compressPermission(state, config) if (effectivePermission === "deny") { @@ -190,10 +210,26 @@ export function createChatMessageTransformHandler( const requestModel = ( lastUserMessage.info as { model?: { providerID?: string; modelID?: string } } ).model - const requestModelLimit = registry.resolveModelLimit( + let requestModelLimit = registry.resolveModelLimit( requestModel?.providerID, requestModel?.modelID, ) + // [FIX #346] Catalog miss: the init-time seed is fire-and-forget and + // races server readiness, so in headless spawn+resume mode the + // catalog can stay empty for the whole process lifetime. During a + // request the server is guaranteed up (we are inside its pipeline), + // so retry hydration once per process before any threshold math. + if ( + requestModelLimit === undefined && + requestModel?.providerID && + requestModel?.modelID + ) { + requestModelLimit = await registry.hydrateAndResolve( + client, + requestModel.providerID, + requestModel.modelID, + ) + } const prevModelID = state.modelID if (requestModelLimit !== undefined) { state.modelContextLimit = requestModelLimit @@ -267,10 +303,11 @@ export function createChatMessageTransformHandler( } ensureBuiltinFiltersRegistered() + const effectiveLimit = resolveEffectiveContextLimit(state, config) applyMessageFilters(output.messages, config.messageFilters, logger, { sessionId: state.sessionId ?? "", isSubAgent: state.isSubAgent, - modelContextLimit: state.modelContextLimit, + modelContextLimit: effectiveLimit?.limit, }) cacheSystemPromptTokens(state, output.messages) assignMessageRefs(state, output.messages) @@ -325,16 +362,40 @@ export function createChatMessageTransformHandler( stripStaleMetadata(output.messages) dropEmptyMessages(output.messages) const postTokens = getCurrentTokenUsage(state, output.messages) + // [FIX #346] Hard guard: if the post-transform context still exceeds + // the model's real request budget (window minus system prompt + tool + // schemas + output-token reserve), the backend will reject the + // request. opencode exits 0 with zero output in that case (upstream + // behavior the plugin cannot change), so this ERROR is the only + // signal that the session has hit the length-rejection wall. + if (postTokens !== undefined && effectiveLimit) { + const budget = + effectiveLimit.limit - (state.systemPromptTokens ?? 0) - OUTPUT_RESERVE_TOKENS + if (postTokens > budget) { + logger.error( + "ACP hard guard: context exceeds model budget after in-flight reduction", + { + session: state.sessionId, + postTokens, + budget, + contextLimit: effectiveLimit.limit, + contextLimitSource: effectiveLimit.source, + hint: "request will likely be rejected; run /compact or start a new session", + }, + ) + } + } logger.info("Chat transform complete", { session: state.sessionId, model: state.modelID, messages: output.messages.length, prePruneTokens, postTokens, - contextLimit: state.modelContextLimit, + contextLimit: effectiveLimit?.limit, + contextLimitSource: effectiveLimit?.source, usagePct: - postTokens !== undefined && state.modelContextLimit - ? `${((postTokens / state.modelContextLimit) * 100).toFixed(1)}%` + postTokens !== undefined && effectiveLimit + ? `${((postTokens / effectiveLimit.limit) * 100).toFixed(1)}%` : undefined, nudged: state.nudges.shouldInjectThisTurn, }) diff --git a/lib/messages/inject/utils.ts b/lib/messages/inject/utils.ts index ceb01edc..3163d064 100644 --- a/lib/messages/inject/utils.ts +++ b/lib/messages/inject/utils.ts @@ -23,7 +23,7 @@ import { } from "../utils" import { getLastUserMessage, isIgnoredUserMessage, isSyntheticMessage } from "../query" import { getCurrentTokenUsage } from "../../token-utils" -import { getActiveSummaryTokenUsage } from "../../state/utils" +import { getActiveSummaryTokenUsage, resolveEffectiveContextLimit } from "../../state/utils" export interface LastUserModelContext { providerId: string | undefined @@ -109,6 +109,13 @@ export function resolveContextTokenLimit( modelId: string | undefined, threshold: "max" | "min", ): number | undefined { + // [FIX #346] Resolve percentage thresholds against the EFFECTIVE limit + // (model limit, or the configured fallback when the model limit is + // unknown). Previously a percentage threshold resolved to undefined when + // state.modelContextLimit was undefined — which, in headless spawn+resume + // mode, was every request — so no nudge anchor could ever be added. + const effectiveLimit = resolveEffectiveContextLimit(state, config) + const parseLimitValue = (limit: number | `${number}%` | undefined): number | undefined => { if (limit === undefined) { return undefined @@ -118,7 +125,7 @@ export function resolveContextTokenLimit( return limit } - if (!limit.endsWith("%") || state.modelContextLimit === undefined) { + if (!limit.endsWith("%") || effectiveLimit === undefined) { return undefined } @@ -129,7 +136,7 @@ export function resolveContextTokenLimit( const roundedPercent = Math.round(parsedPercent) const clampedPercent = Math.max(0, Math.min(100, roundedPercent)) - return Math.round((clampedPercent / 100) * state.modelContextLimit) + return Math.round((clampedPercent / 100) * effectiveLimit.limit) } // Per-provider / per-model nested override (compress.providers, issue #344): @@ -205,11 +212,16 @@ export function isContextOverLimits( } } + // [FIX #346] Report the EFFECTIVE limit (model or fallback) so downstream + // consumers (emergency override, usage displays, block guidance) operate + // against the same window the thresholds above were computed from. + const effectiveLimit = resolveEffectiveContextLimit(state, config) + return { overMaxLimit, overMinLimit, currentTokens, - modelContextLimit: state.modelContextLimit, + modelContextLimit: effectiveLimit?.limit, } } diff --git a/lib/messages/truncate-tools.ts b/lib/messages/truncate-tools.ts index f0077fe7..53c64f7f 100644 --- a/lib/messages/truncate-tools.ts +++ b/lib/messages/truncate-tools.ts @@ -2,6 +2,7 @@ import { SessionState, WithParts } from "../state" import type { PluginConfig } from "../config" import { Logger } from "../logger" import { getCurrentTokenUsage, countTokens, extractCompletedToolOutput } from "../token-utils" +import { resolveEffectiveContextLimit } from "../state/utils" const TRUNCATION_MARKER = "[truncated for context space" const MIN_OUTPUT_TOKENS = 1000 @@ -9,6 +10,14 @@ const KEEP_PREFIX_CHARS = 2000 const KEEP_SUFFIX_CHARS = 2000 const PROTECT_RECENT_MESSAGES = 3 +// [FIX #346] Reserve for the model's max output tokens (max_tokens) that the +// serving backend appends to every request. The system prompt + tool schemas +// are already covered by the state.systemPromptTokens estimate. Without this +// reserve, a threshold at 100% of the window starts truncating only AFTER the +// request has already exceeded max_model_len (the production wall in #346: +// ~229k conversation + ~17k system + 16k max_tokens > 262144). +export const OUTPUT_RESERVE_TOKENS = 16384 + function parseGcThreshold( threshold: number | `${number}%` | undefined, modelContextLimit: number, @@ -31,12 +40,30 @@ export function truncateLargeToolOutputs( logger: Logger, messages: WithParts[], ): void { - if (!state.modelContextLimit) return + const effective = resolveEffectiveContextLimit(state, config) + if (!effective) return const currentTokens = getCurrentTokenUsage(state, messages) if (currentTokens === 0) return - const threshold = parseGcThreshold(config.gc?.majorGcThresholdPercent, state.modelContextLimit) + // [FIX #346] The serving wall is NOT the full window: the request also + // carries the system prompt + tool schemas (state.systemPromptTokens) and + // the model's max output tokens (OUTPUT_RESERVE_TOKENS). Start truncating + // at min(configured threshold, window − overhead) so the request still + // fits. Users with larger max_tokens can lower gc.majorGcThresholdPercent + // — the min() keeps the stricter bound. + const configuredThreshold = parseGcThreshold(config.gc?.majorGcThresholdPercent, effective.limit) + const overhead = (state.systemPromptTokens ?? 0) + OUTPUT_RESERVE_TOKENS + const threshold = Math.min(configuredThreshold, effective.limit - overhead) + if (threshold <= 0) { + logger.error("ACP: model context window too small to fit overhead", { + session: state.sessionId, + limit: effective.limit, + contextLimitSource: effective.source, + overhead, + }) + return + } if (currentTokens < threshold) return const protectedIndex = messages.length - PROTECT_RECENT_MESSAGES @@ -97,6 +124,8 @@ export function truncateLargeToolOutputs( estimatedSavedTokens: Math.round(savedTokens), currentTokens, threshold, + contextLimit: effective.limit, + contextLimitSource: effective.source, }) } } diff --git a/lib/state/state.ts b/lib/state/state.ts index 431a77ab..27ea2d02 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -114,6 +114,30 @@ export class SessionStateRegistry { return this.catalog.hydrateFromClient(client) } + // [FIX #346] The init-time seed (above) is fire-and-forget and races + // server readiness: in headless spawn+resume mode the provider-config + // call can fail before the server is up, leaving the catalog empty for + // the process's lifetime. During a request the server is guaranteed up + // (we are inside its pipeline), so on a catalog miss we retry hydration + // once per process before giving up (the fallback limit then applies). + private lazyHydrated = false + + async hydrateAndResolve( + client: unknown, + providerId: string, + modelId: string, + ): Promise { + const existing = this.catalog.resolve(providerId, modelId) + if (existing !== undefined) { + return existing + } + if (!this.lazyHydrated) { + this.lazyHydrated = true + await this.catalog.hydrateFromClient(client) + } + return this.catalog.resolve(providerId, modelId) + } + get(sessionId: string): SessionState | undefined { return this.states.get(sessionId) } diff --git a/lib/state/utils.ts b/lib/state/utils.ts index ecebf5d8..3132f5c9 100644 --- a/lib/state/utils.ts +++ b/lib/state/utils.ts @@ -5,6 +5,7 @@ import type { SessionState, WithParts, } from "./types" +import type { PluginConfig } from "../config" import { isIgnoredUserMessage, messageHasCompress } from "../messages/query" import { isMessageWithInfo } from "../messages/shape" import { countTokens } from "../token-utils" @@ -407,3 +408,32 @@ export function resetOnCompaction(state: SessionState): void { nextRef: 1, } } + +/** + * The context window ACP's safety net should operate against. + * + * `source: "model"` — the real model limit (learned from the system hook, + * persisted, or resolved from the catalog). `source: "fallback"` — the + * configured `compress.contextLimitFallback`, used only when the model limit + * is unknown. Returns `undefined` when neither is available (fallback + * disabled via `contextLimitFallback: 0` and no model limit) — the legacy + * "no safety net" behavior. + */ +export interface EffectiveContextLimit { + limit: number + source: "model" | "fallback" +} + +export function resolveEffectiveContextLimit( + state: SessionState, + config: PluginConfig, +): EffectiveContextLimit | undefined { + if (typeof state.modelContextLimit === "number" && state.modelContextLimit > 0) { + return { limit: state.modelContextLimit, source: "model" } + } + const fallback = config.compress.contextLimitFallback + if (typeof fallback === "number" && fallback > 0) { + return { limit: fallback, source: "fallback" } + } + return undefined +} diff --git a/tests/context-limit-fallback.test.ts b/tests/context-limit-fallback.test.ts new file mode 100644 index 00000000..0c88953e --- /dev/null +++ b/tests/context-limit-fallback.test.ts @@ -0,0 +1,220 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import type { SessionState, WithParts } from "../lib/state/types" +import type { PluginConfig } from "../lib/config" +import { resolveEffectiveContextLimit } from "../lib/state/utils" +import { isContextOverLimits } from "../lib/messages/inject/utils" + +// Issue #346: in headless spawn+resume mode the model limit was never known +// (no persistence, empty catalog), so percentage thresholds resolved to +// undefined and every safety net (nudges, GC, in-flight truncation) was +// silently disabled. compress.contextLimitFallback restores the safety net +// against a configurable window when the model limit is unknown. + +function makeState(modelContextLimit?: number): SessionState { + return { + sessionId: "session-fallback", + isSubAgent: false, + modelContextLimit, + lastCompaction: 0, + prune: { + tools: new Map(), + messages: { + byMessageId: new Map(), + blocksById: new Map(), + activeBlockIds: new Set(), + activeByAnchorMessageId: new Map(), + nextBlockId: 1, + nextRunId: 1, + markedForCleanup: new Set(), + }, + }, + nudges: { + contextLimitAnchors: new Set(), + turnNudgeAnchors: new Set(), + iterationNudgeAnchors: new Set(), + lastPerMessageNudgeTurn: 0, + lastPerMessageNudgeTokens: undefined, + lastNudgeShownTokens: undefined, + lastToolOutputNudgeTokens: undefined, + lastTier2NudgeTokens: undefined, + lastTier3NudgeTokens: undefined, + shouldInjectThisTurn: undefined, + baselineLocked: false, + }, + stats: { pruneTokenCounter: 0, totalPruneTokens: 0 }, + messageIds: { byRawId: new Map(), byRef: new Map(), nextRef: 1 }, + compressionTiming: { pending: new Map(), completed: [] }, + toolParameters: new Map(), + } as unknown as SessionState +} + +function makeConfig(fallback: number | undefined): PluginConfig { + return { + enabled: true, + autoUpdate: false, + debug: false, + pruneNotification: "off", + pruneNotificationType: "chat", + commands: { enabled: true, protectedTools: [] }, + experimental: { allowSubAgents: false, customPrompts: false }, + protectedFilePatterns: [], + compress: { + permission: "allow", + showCompression: false, + summaryBuffer: false, + maxContextLimit: "80%", + minContextLimit: "80%", + contextLimitFallback: fallback, + nudgeFrequency: 5, + iterationNudgeThreshold: 15, + nudgeForce: "soft", + protectedTools: [], + protectTags: false, + protectUserMessages: false, + }, + strategies: { + deduplication: { enabled: true, protectedTools: [] }, + purgeErrors: { enabled: true, turns: 4, protectedTools: [] }, + }, + gc: { + algorithm: "truncate", + promotionThreshold: 5, + maxBlockAge: 15, + maxOldGenSummaryLength: 3000, + majorGcThresholdPercent: "100%", + batchCleanup: { + lowThreshold: "60%", + highThreshold: "75%", + forceThreshold: "90%", + }, + }, + } as unknown as PluginConfig +} + +function makeUserMessage(id: string, text: string): WithParts { + return { + info: { + id, + role: "user", + sessionID: "session-fallback", + createdAt: new Date().toISOString(), + } as any, + parts: [{ type: "text", text }] as any, + } +} + +function makeAssistantWithTokens(id: string, inputTokens: number): WithParts { + return { + info: { + id, + role: "assistant", + sessionID: "session-fallback", + createdAt: new Date().toISOString(), + tokens: { input: inputTokens, output: 100, reasoning: 0, cache: { read: 0, write: 0 } }, + } as any, + parts: [{ type: "text", text: "ok" }] as any, + } +} + +// ─── resolveEffectiveContextLimit ───────────────────────────────────────────── + +test("resolveEffectiveContextLimit: known model limit wins over fallback", () => { + const state = makeState(200_000) + const config = makeConfig(128_000) + + assert.deepEqual(resolveEffectiveContextLimit(state, config), { + limit: 200_000, + source: "model", + }) +}) + +test("resolveEffectiveContextLimit: fallback used when model limit unknown", () => { + const state = makeState(undefined) + const config = makeConfig(128_000) + + assert.deepEqual(resolveEffectiveContextLimit(state, config), { + limit: 128_000, + source: "fallback", + }) +}) + +test("resolveEffectiveContextLimit: zero model limit falls through to fallback", () => { + const state = makeState(0) + const config = makeConfig(128_000) + + assert.deepEqual(resolveEffectiveContextLimit(state, config), { + limit: 128_000, + source: "fallback", + }) +}) + +test("resolveEffectiveContextLimit: fallback 0 disables the fallback (legacy behavior)", () => { + const state = makeState(undefined) + const config = makeConfig(0) + + assert.equal(resolveEffectiveContextLimit(state, config), undefined) +}) + +test("resolveEffectiveContextLimit: undefined fallback disables the fallback", () => { + const state = makeState(undefined) + const config = makeConfig(undefined) + + assert.equal(resolveEffectiveContextLimit(state, config), undefined) +}) + +// ─── isContextOverLimits with the fallback ──────────────────────────────────── + +test("isContextOverLimits: fallback drives thresholds when model limit unknown (#346)", () => { + const state = makeState(undefined) + const config = makeConfig(128_000) + const messages = [makeUserMessage("u1", "hi"), makeAssistantWithTokens("a1", 110_000)] + + const result = isContextOverLimits(config, state, undefined, undefined, messages) + + // currentTokens = 110_000 + 100 output = 110_100; 80% of 128_000 = 102_400 + assert.equal(result.currentTokens, 110_100) + assert.equal(result.overMinLimit, true) + assert.equal(result.overMaxLimit, true) + assert.equal(result.modelContextLimit, 128_000) +}) + +test("isContextOverLimits: below the fallback threshold no limit is crossed", () => { + const state = makeState(undefined) + const config = makeConfig(128_000) + const messages = [makeUserMessage("u1", "hi"), makeAssistantWithTokens("a1", 50_000)] + + const result = isContextOverLimits(config, state, undefined, undefined, messages) + + // 50_100 < 102_400 (80% of 128_000) + assert.equal(result.currentTokens, 50_100) + assert.equal(result.overMinLimit, false) + assert.equal(result.overMaxLimit, false) + assert.equal(result.modelContextLimit, 128_000) +}) + +test("isContextOverLimits: fallback disabled (0) restores legacy no-threshold behavior", () => { + const state = makeState(undefined) + const config = makeConfig(0) + const messages = [makeUserMessage("u1", "hi"), makeAssistantWithTokens("a1", 110_000)] + + const result = isContextOverLimits(config, state, undefined, undefined, messages) + + assert.equal(result.overMinLimit, false) + assert.equal(result.overMaxLimit, false) + assert.equal(result.modelContextLimit, undefined) +}) + +test("isContextOverLimits: known model limit takes precedence over fallback", () => { + const state = makeState(200_000) + const config = makeConfig(128_000) + const messages = [makeUserMessage("u1", "hi"), makeAssistantWithTokens("a1", 170_000)] + + const result = isContextOverLimits(config, state, undefined, undefined, messages) + + // 80% of 200_000 = 160_000; 170_100 > 160_000 → over, against the MODEL window + assert.equal(result.overMinLimit, true) + assert.equal(result.overMaxLimit, true) + assert.equal(result.modelContextLimit, 200_000) +}) diff --git a/tests/model-switch-limits.test.ts b/tests/model-switch-limits.test.ts index 47c38a13..5b51198c 100644 --- a/tests/model-switch-limits.test.ts +++ b/tests/model-switch-limits.test.ts @@ -17,7 +17,7 @@ import assert from "node:assert/strict" import test from "node:test" -import { mkdtempSync, rmSync } from "node:fs" +import { mkdtempSync, readFileSync, rmSync } from "node:fs" import { join } from "node:path" import { tmpdir } from "node:os" import type { PluginConfig } from "../lib/config" @@ -154,9 +154,12 @@ function collectText(messages: WithParts[]): string { async function runTransform(opts: { currentTokens: number modelId: string - initialLimit: number + initialLimit: number | undefined initialModel?: { providerID: string; modelID: string } catalog?: Array<[providerId: string, modelId: string, limit: number]> + client?: unknown + logger?: Logger + config?: PluginConfig }): Promise<{ text: string; state: SessionState }> { const tempDir = mkdtempSync(join(tmpdir(), "acp-model-switch-")) process.env.XDG_DATA_HOME = tempDir @@ -180,10 +183,10 @@ async function runTransform(opts: { } const handler = createChatMessageTransformHandler( - createMockClient(), + opts.client ?? createMockClient(), registry, - new Logger(false), - buildConfig(), + opts.logger ?? new Logger(false), + opts.config ?? buildConfig(), createMockPrompts(), { global: undefined, agents: {} }, ) @@ -391,3 +394,210 @@ test("hydrateModelLimitsFromClient tolerates missing and throwing clients", asyn 0, ) }) + +// ─── Issue #346: spawn+resume loses the limit (no persistence, empty catalog) ─ + +test("catalog miss + provider config available: lazy hydration resolves the limit (#346)", async () => { + // Production path: headless spawn+resume, init-time seed raced server + // readiness and left the catalog empty. During the request the server is + // up, so a one-time lazy hydration must recover the limit. + const { state } = await runTransform({ + currentTokens: 100_000, + modelId: NEW_MODEL, + initialLimit: undefined, + client: { + session: { get: async () => ({ data: { parentID: null } }) }, + config: { + providers: async () => ({ + data: { + providers: [ + { + id: PROVIDER, + models: { [NEW_MODEL]: { limit: { context: NEW_LIMIT } } }, + }, + ], + }, + }), + }, + }, + }) + + assert.equal(state.modelContextLimit, NEW_LIMIT, "limit must resolve via lazy hydration") +}) + +test("system.transform persists the limit so spawned processes resume with it (#346)", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "acp-persist-")) + process.env.XDG_DATA_HOME = tempDir + process.env.XDG_CONFIG_HOME = tempDir + + try { + const state = createSessionState() + state.sessionId = SID + const registry = createTestRegistry(state) + const handler = createSystemPromptHandler( + registry, + new Logger(false), + buildConfig(), + createMockPrompts(), + ) + + await handler( + { + sessionID: SID, + model: { id: NEW_MODEL, providerID: PROVIDER, limit: { context: NEW_LIMIT } }, + }, + { system: ["base system prompt"] }, + ) + // saveSessionState is fire-and-forget — give the write a tick to land. + await new Promise((resolve) => setTimeout(resolve, 100)) + + const file = join(tempDir, "opencode", "storage", "plugin", "acp", `${SID}.json`) + const persisted = JSON.parse(readFileSync(file, "utf8")) + assert.equal(persisted.modelContextLimit, NEW_LIMIT) + assert.equal(persisted.modelProviderID, PROVIDER) + assert.equal(persisted.modelID, NEW_MODEL) + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } +}) + +test("internal-agent system prompts must not overwrite the session limit (#346)", async () => { + // Title/summary/compaction agents run on their own small model; their + // system.transform must not corrupt the session's real limit. + const state = createSessionState() + state.sessionId = SID + state.modelContextLimit = OLD_LIMIT + state.modelProviderID = PROVIDER + state.modelID = OLD_MODEL + const registry = createTestRegistry(state) + const handler = createSystemPromptHandler( + registry, + new Logger(false), + buildConfig(), + createMockPrompts(), + ) + + await handler( + { + sessionID: SID, + model: { id: "title-model", providerID: PROVIDER, limit: { context: 8_000 } }, + }, + { system: ["You are a title generator for conversations."] }, + ) + + assert.equal(state.modelContextLimit, OLD_LIMIT, "title-agent limit must not overwrite") + assert.equal(state.modelID, OLD_MODEL) +}) + +test("hydrateAndResolve: cached hit never touches the client", async () => { + const registry = new SessionStateRegistry(new Logger(false)) + registry.recordModelLimit(PROVIDER, NEW_MODEL, NEW_LIMIT) + let calls = 0 + const client = { + config: { + providers: async () => { + calls++ + return { data: { providers: [] } } + }, + }, + } + + assert.equal(await registry.hydrateAndResolve(client, PROVIDER, NEW_MODEL), NEW_LIMIT) + assert.equal(calls, 0) +}) + +test("hydrateAndResolve: hydrates at most once per process, then stops retrying (#346)", async () => { + const registry = new SessionStateRegistry(new Logger(false)) + let calls = 0 + const client = { + config: { + providers: async () => { + calls++ + return { + data: { + providers: [ + { + id: PROVIDER, + models: { [NEW_MODEL]: { limit: { context: NEW_LIMIT } } }, + }, + ], + }, + } + }, + }, + } + + assert.equal(await registry.hydrateAndResolve(client, PROVIDER, "missing-1"), undefined) + assert.equal(await registry.hydrateAndResolve(client, PROVIDER, "missing-2"), undefined) + assert.equal(calls, 1, "second miss must not re-hydrate") + assert.equal(await registry.hydrateAndResolve(client, PROVIDER, NEW_MODEL), NEW_LIMIT) + assert.equal(calls, 1, "cached hit after hydration must not re-hydrate") +}) + +test("hydrateAndResolve: tolerates throwing clients", async () => { + const registry = new SessionStateRegistry(new Logger(false)) + const client = { + config: { providers: async () => { throw new Error("offline") } }, + } + + assert.equal(await registry.hydrateAndResolve(client, PROVIDER, NEW_MODEL), undefined) +}) + +test("hard guard: ERROR log when post-transform context exceeds the model budget (#346)", async () => { + const errors: string[] = [] + const logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: (msg: string) => { + errors.push(msg) + }, + saveContext: () => {}, + child: () => logger, + } as unknown as Logger + + // 190k post-transform on a 200k window: budget = 200_000 − ~1k system + // prompt − 16_384 output reserve ≈ 182.6k < 190.05k → the request would + // exceed max_model_len; the guard must log a loud error. + await runTransform({ + currentTokens: 190_000, + modelId: OLD_MODEL, + initialLimit: OLD_LIMIT, + initialModel: { providerID: PROVIDER, modelID: OLD_MODEL }, + catalog: [[PROVIDER, OLD_MODEL, OLD_LIMIT]], + logger, + }) + + assert.ok( + errors.some((e) => e.includes("ACP hard guard")), + `expected an ACP hard guard error, got: ${JSON.stringify(errors)}`, + ) +}) + +test("hard guard: silent when post-transform context fits the budget", async () => { + const errors: string[] = [] + const logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: (msg: string) => { + errors.push(msg) + }, + saveContext: () => {}, + child: () => logger, + } as unknown as Logger + + await runTransform({ + currentTokens: 100_000, + modelId: OLD_MODEL, + initialLimit: OLD_LIMIT, + initialModel: { providerID: PROVIDER, modelID: OLD_MODEL }, + catalog: [[PROVIDER, OLD_MODEL, OLD_LIMIT]], + logger, + }) + + assert.ok( + !errors.some((e) => e.includes("ACP hard guard")), + `unexpected ACP hard guard error: ${JSON.stringify(errors)}`, + ) +}) diff --git a/tests/registry-stub.ts b/tests/registry-stub.ts index 7bb5f896..fc7a61fb 100644 --- a/tests/registry-stub.ts +++ b/tests/registry-stub.ts @@ -69,5 +69,19 @@ export function createTestRegistry(seedState: SessionState) { ): number | undefined { return modelLimits.resolve(providerId, modelId) }, + // [FIX #346] Mirrors SessionStateRegistry.hydrateAndResolve — the + // messages transform calls it on a catalog miss. + async hydrateAndResolve( + client: unknown, + providerId: string, + modelId: string, + ): Promise { + const existing = modelLimits.resolve(providerId, modelId) + if (existing !== undefined) { + return existing + } + await modelLimits.hydrateFromClient(client) + return modelLimits.resolve(providerId, modelId) + }, } } diff --git a/tests/truncate-tools.test.ts b/tests/truncate-tools.test.ts index 2d8f1f77..3ff6d607 100644 --- a/tests/truncate-tools.test.ts +++ b/tests/truncate-tools.test.ts @@ -165,7 +165,9 @@ test("Truncation: does nothing when context is below threshold", () => { }) test("Truncation: truncates largest tool output at threshold", () => { - const state = makeState(1000) + // [FIX #346] Window must exceed OUTPUT_RESERVE_TOKENS (16384) for the + // overhead-aware threshold to be positive. + const state = makeState(200000) const config = makeConfig({ majorGcThresholdPercent: "100%" }) const messages: WithParts[] = [] @@ -173,7 +175,8 @@ test("Truncation: truncates largest tool output at threshold", () => { messages.push(makeToolMessage(`msg-${i}`, LARGE_OUTPUT)) } messages.push(makeTextMessage("msg-user", "hello")) - messages.push(makeAssistantWithTokens("msg-asst", 1000)) + // [FIX #346] 200_000 + output 100 = 200_100 ≥ threshold 200_000 + messages.push(makeAssistantWithTokens("msg-asst", 200_000)) truncateLargeToolOutputs(state, config, noopLogger, messages) @@ -299,7 +302,9 @@ test("Truncation: no crash when no tool outputs exist", () => { }) test("Truncation: preserves prefix and suffix of truncated output", () => { - const state = makeState(1000) + // [FIX #346] Window must exceed OUTPUT_RESERVE_TOKENS (16384) so the + // overhead-aware threshold stays positive. + const state = makeState(200000) const config = makeConfig() const prefix = "START_MARKER_" + "p".repeat(2000) @@ -312,7 +317,8 @@ test("Truncation: preserves prefix and suffix of truncated output", () => { messages.push(makeToolMessage(`msg-${i}`, fullOutput)) } messages.push(makeTextMessage("msg-6", "text")) - messages.push(makeAssistantWithTokens("msg-7", 1000)) + // [FIX #346] 200_000 + output 100 = 200_100 ≥ threshold 200_000 + messages.push(makeAssistantWithTokens("msg-7", 200_000)) truncateLargeToolOutputs(state, config, noopLogger, messages) @@ -356,3 +362,99 @@ test("Truncation equivalence: output never longer than input", () => { ) } }) + +// ─── Issue #346: the serving wall is window − system prompt − max_tokens ───── + +test("production wall repro (#346): truncates when conversation + overhead exceeds the window", () => { + // Production numbers from the issue: 229_479 conversation tokens on a + // 262_144 window with ~17k system prompt + 16_384 max_tokens. The old + // 100%-of-window threshold started truncating only at 262_144 — AFTER the + // request had already exceeded max_model_len (immediate rejection, silent + // empty run, retry loop). The overhead-aware threshold is + // min(262_144, 262_144 − 17_000 − 16_384) = 228_760 ≤ 229_479 → must fire. + const state = makeState(262_144) + state.systemPromptTokens = 17_000 + const config = makeConfig({ majorGcThresholdPercent: "100%" }) + + const messages: WithParts[] = [makeTextMessage("u0", "start")] + for (let i = 1; i <= 10; i++) { + messages.push(makeToolMessage(`t${i}`, LARGE_OUTPUT)) + } + messages.push(makeTextMessage("u1", "latest question")) + // 229_379 + output 100 = 229_479 (the exact production token count). + messages.push(makeAssistantWithTokens("a1", 229_379)) + + truncateLargeToolOutputs(state, config, noopLogger, messages) + + let truncatedCount = 0 + for (const m of messages) { + const output = (m.parts[0] as any).state?.output + if (typeof output === "string" && output.includes("[truncated for context space")) { + truncatedCount++ + } + } + assert.ok(truncatedCount > 0, "must truncate at the overhead-aware threshold") +}) + +test("window too small for overhead: bails with ERROR instead of truncating", () => { + const state = makeState(10_000) + const config = makeConfig({ majorGcThresholdPercent: "100%" }) + const errors: string[] = [] + const logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: (msg: string) => { + errors.push(msg) + }, + saveContext: () => {}, + child: () => logger, + } as unknown as Logger + + const messages: WithParts[] = [ + makeToolMessage("t1", LARGE_OUTPUT), + makeTextMessage("u1", "latest question"), + makeAssistantWithTokens("a1", 1_000), + ] + + truncateLargeToolOutputs(state, config, logger, messages) + + const output = (messages[0]!.parts[0] as any).state.output + assert.ok( + !output.includes("[truncated for context space"), + "must not truncate when the window cannot fit the overhead", + ) + assert.ok( + errors.some((e) => e.includes("too small to fit overhead")), + `expected an overhead error, got: ${JSON.stringify(errors)}`, + ) +}) + +test("fallback limit drives truncation when model limit unknown (#346)", () => { + // The production sessions never learned the model limit (spawn+resume, + // empty catalog). With compress.contextLimitFallback the safety net must + // still work against the fallback window. + const state = makeState() + state.modelContextLimit = undefined + const config = makeConfig({ majorGcThresholdPercent: "100%" }) + config.compress.contextLimitFallback = 200_000 + + const messages: WithParts[] = [makeTextMessage("u0", "start")] + for (let i = 1; i <= 10; i++) { + messages.push(makeToolMessage(`t${i}`, LARGE_OUTPUT)) + } + messages.push(makeTextMessage("u1", "latest question")) + // 200_000 + output 100 = 200_100 ≥ threshold min(200_000, 183_616) + messages.push(makeAssistantWithTokens("a1", 200_000)) + + truncateLargeToolOutputs(state, config, noopLogger, messages) + + let truncatedCount = 0 + for (const m of messages) { + const output = (m.parts[0] as any).state?.output + if (typeof output === "string" && output.includes("[truncated for context space")) { + truncatedCount++ + } + } + assert.ok(truncatedCount > 0, "fallback limit must drive in-flight truncation") +}) From e5f01d6abd835e75423f1fbcfbc885472b3b42cb Mon Sep 17 00:00:00 2001 From: ework-agent Date: Sat, 29 Aug 2026 01:42:27 +0800 Subject: [PATCH 2/2] fix: address review findings for #346 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CONFIGURATION.md | 2 +- CONFIGURATION.zh-CN.md | 2 +- dcp.schema.json | 2 +- lib/config.ts | 8 ++-- lib/hooks.ts | 15 ++++-- lib/messages/truncate-tools.ts | 35 +++++++++----- lib/state/state.ts | 10 ++-- tests/context-limit-fallback.test.ts | 71 ++++++++++++++++++++++++++-- tests/model-switch-limits.test.ts | 30 ++++++++++-- tests/registry-stub.ts | 9 +++- tests/truncate-tools.test.ts | 66 +++++++++++++++++++------- 11 files changed, 196 insertions(+), 54 deletions(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index e7eca3f6..ea6527ce 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -194,7 +194,7 @@ Core compression behavior. - **Type:** `number` - **Default:** `128000` - **Status:** ACTIVE -- **Description:** Fallback context window (absolute tokens) used when the model's limit is unknown — e.g. custom providers with no declared limit, or headless spawn+resume sessions where the limit was never learned. Drives all percentage thresholds (`maxContextLimit`/`minContextLimit`), the emergency nudge override, batch cleanup GC, and in-flight tool-output truncation. The real model limit always takes precedence when known. Set to `0` to disable the fallback (legacy behavior: no safety net until the limit is learned). +- **Description:** Fallback context window (absolute tokens) used when the model's limit is unknown — e.g. custom providers with no declared limit, headless spawn+resume sessions where the limit was never learned, or the brief window after a model switch invalidates a stale limit. Drives all percentage thresholds (`maxContextLimit`/`minContextLimit`), the emergency nudge override, batch cleanup GC, and in-flight tool-output truncation. The real model limit always takes precedence when known, as do per-model overrides (`modelMaxLimits`/`modelMinLimits`). Set to `0` to disable the fallback (legacy behavior: no safety net until the limit is learned). #### `compress.nudgeFrequency` - **Type:** `number` diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index f0f5ef18..4282f459 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -194,7 +194,7 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): - **类型:** `number` - **默认值:** `128000` - **状态:** ACTIVE -- **说明:** 当模型上下文窗口未知时使用的回退窗口(绝对 token 数)——例如未声明 limit 的自定义 provider,或从未学到 limit 的 headless spawn+resume 会话。驱动所有百分比阈值(`maxContextLimit`/`minContextLimit`)、紧急 nudge 覆盖、批量清理 GC 与 in-flight 工具输出截断。已知真实模型 limit 时始终优先。设为 `0` 可禁用回退(旧行为:学到 limit 前无安全网)。 +- **说明:** 当模型上下文窗口未知时使用的回退窗口(绝对 token 数)——例如未声明 limit 的自定义 provider、从未学到 limit 的 headless spawn+resume 会话,或模型切换使旧 limit 失效后的短暂窗口。驱动所有百分比阈值(`maxContextLimit`/`minContextLimit`)、紧急 nudge 覆盖、批量清理 GC 与 in-flight 工具输出截断。已知真实模型 limit 时始终优先,按模型覆盖(`modelMaxLimits`/`modelMinLimits`)同样优先。设为 `0` 可禁用回退(旧行为:学到 limit 前无安全网)。 #### `compress.nudgeFrequency` - **类型:** `number` diff --git a/dcp.schema.json b/dcp.schema.json index 4db69c6b..b00c2a3e 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -193,7 +193,7 @@ } }, "contextLimitFallback": { - "description": "Fallback context window (absolute tokens) used when the model's limit is unknown (e.g. custom providers with no declared limit). Drives nudge thresholds, emergency override, GC, and in-flight truncation. Set to 0 to disable the fallback (legacy behavior: no safety net until the limit is learned).", + "description": "Fallback context window (absolute tokens) used when the model's limit is unknown (e.g. custom providers with no declared limit, or the brief window after a model switch invalidates a stale limit). Drives nudge thresholds, emergency override, GC, and in-flight truncation. Per-model limits (modelMaxLimits/modelMinLimits) take precedence. Set to 0 to disable the fallback (legacy behavior: no safety net until the limit is learned).", "type": "number", "default": 128000, "minimum": 0 diff --git a/lib/config.ts b/lib/config.ts index a0c40add..18a09f07 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -65,9 +65,11 @@ export interface CompressConfig { providers?: Record /** * Fallback context window (absolute tokens) used when the model's limit is - * unknown (e.g. custom providers with no declared limit). Default: 128000. - * Set to 0 to disable the fallback (legacy behavior: no safety net until - * the limit is learned). + * unknown (e.g. custom providers with no declared limit, or the brief + * window after a model switch invalidates a stale limit). Default: 128000. + * Per-model limits (modelMaxLimits/modelMinLimits) take precedence. Set to + * 0 to disable the fallback (legacy behavior: no safety net until the + * limit is learned). */ contextLimitFallback?: number nudgeFrequency: number diff --git a/lib/hooks.ts b/lib/hooks.ts index c01e5bf7..0bfc075e 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -124,15 +124,22 @@ export function createSystemPromptHandler( const limit = input.model.limit.context const providerID = input.model?.providerID const modelID = input.model?.id + // Identity fields are only written when present: a limit without + // identity must not clobber the pair the messages hook relies on + // for staleness detection (#312). const changed = state.modelContextLimit !== limit || - state.modelProviderID !== providerID || - state.modelID !== modelID + (providerID !== undefined && state.modelProviderID !== providerID) || + (modelID !== undefined && state.modelID !== modelID) state.modelContextLimit = limit // [FIX #312 follow-up] Record WHICH model the limit belongs to so // the messages hook can detect staleness on a catalog miss. - state.modelProviderID = providerID - state.modelID = modelID + if (providerID !== undefined) { + state.modelProviderID = providerID + } + if (modelID !== undefined) { + state.modelID = modelID + } if (changed) { saveSessionState(state, logger).catch(() => {}) } diff --git a/lib/messages/truncate-tools.ts b/lib/messages/truncate-tools.ts index 53c64f7f..87694835 100644 --- a/lib/messages/truncate-tools.ts +++ b/lib/messages/truncate-tools.ts @@ -18,6 +18,10 @@ const PROTECT_RECENT_MESSAGES = 3 // ~229k conversation + ~17k system + 16k max_tokens > 262144). export const OUTPUT_RESERVE_TOKENS = 16384 +// Sessions that already received the "window too small" ERROR (logged once +// per session — the condition is stable for the process's lifetime). +const overheadErrorLogged = new Set() + function parseGcThreshold( threshold: number | `${number}%` | undefined, modelContextLimit: number, @@ -47,21 +51,30 @@ export function truncateLargeToolOutputs( if (currentTokens === 0) return // [FIX #346] The serving wall is NOT the full window: the request also - // carries the system prompt + tool schemas (state.systemPromptTokens) and - // the model's max output tokens (OUTPUT_RESERVE_TOKENS). Start truncating - // at min(configured threshold, window − overhead) so the request still - // fits. Users with larger max_tokens can lower gc.majorGcThresholdPercent - // — the min() keeps the stricter bound. + // carries the model's max output tokens (OUTPUT_RESERVE_TOKENS) and — + // when currentTokens comes from the fallback count (no provider usage + // data, e.g. after consecutive rejected requests) — the system prompt + + // tool schemas, which that count does not include. Subtracting both is + // the conservative bound: exact for fallback counts, a safety margin + // when provider usage already includes the system prompt. Users with + // larger max_tokens can lower gc.majorGcThresholdPercent — the min() + // keeps the stricter bound. const configuredThreshold = parseGcThreshold(config.gc?.majorGcThresholdPercent, effective.limit) const overhead = (state.systemPromptTokens ?? 0) + OUTPUT_RESERVE_TOKENS const threshold = Math.min(configuredThreshold, effective.limit - overhead) if (threshold <= 0) { - logger.error("ACP: model context window too small to fit overhead", { - session: state.sessionId, - limit: effective.limit, - contextLimitSource: effective.source, - overhead, - }) + // The condition is stable for the life of the process (limit and the + // system-prompt estimate do not flip back), so log once per session. + const sessionKey = state.sessionId ?? "unknown" + if (!overheadErrorLogged.has(sessionKey)) { + overheadErrorLogged.add(sessionKey) + logger.error("ACP: model context window too small to fit overhead", { + session: state.sessionId, + limit: effective.limit, + contextLimitSource: effective.source, + overhead, + }) + } return } if (currentTokens < threshold) return diff --git a/lib/state/state.ts b/lib/state/state.ts index 27ea2d02..cff1189b 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -120,7 +120,9 @@ export class SessionStateRegistry { // the process's lifetime. During a request the server is guaranteed up // (we are inside its pipeline), so on a catalog miss we retry hydration // once per process before giving up (the fallback limit then applies). - private lazyHydrated = false + // The in-flight promise (not a boolean) lets concurrent callers await the + // same hydration instead of skipping it. + private lazyHydration: Promise | undefined async hydrateAndResolve( client: unknown, @@ -131,10 +133,8 @@ export class SessionStateRegistry { if (existing !== undefined) { return existing } - if (!this.lazyHydrated) { - this.lazyHydrated = true - await this.catalog.hydrateFromClient(client) - } + this.lazyHydration ??= this.catalog.hydrateFromClient(client) + await this.lazyHydration return this.catalog.resolve(providerId, modelId) } diff --git a/tests/context-limit-fallback.test.ts b/tests/context-limit-fallback.test.ts index 0c88953e..431aa788 100644 --- a/tests/context-limit-fallback.test.ts +++ b/tests/context-limit-fallback.test.ts @@ -5,6 +5,8 @@ import type { SessionState, WithParts } from "../lib/state/types" import type { PluginConfig } from "../lib/config" import { resolveEffectiveContextLimit } from "../lib/state/utils" import { isContextOverLimits } from "../lib/messages/inject/utils" +import { injectCompressNudges } from "../lib/messages/inject/inject" +import { Logger } from "../lib/logger" // Issue #346: in headless spawn+resume mode the model limit was never known // (no persistence, empty catalog), so percentage thresholds resolved to @@ -74,10 +76,6 @@ function makeConfig(fallback: number | undefined): PluginConfig { protectTags: false, protectUserMessages: false, }, - strategies: { - deduplication: { enabled: true, protectedTools: [] }, - purgeErrors: { enabled: true, turns: 4, protectedTools: [] }, - }, gc: { algorithm: "truncate", promotionThreshold: 5, @@ -218,3 +216,68 @@ test("isContextOverLimits: known model limit takes precedence over fallback", () assert.equal(result.overMaxLimit, true) assert.equal(result.modelContextLimit, 200_000) }) + +// ─── §5.7 growth cycle with the fallback (multi-turn, side-effect asserts) ── + +test("growth cycle: fallback drives the nudge across turns without a known limit (#346, §5.7)", () => { + // Production shape: model limit unknown (spawn+resume), fallback 128K, + // preserveRecentMessages 20 (production default). Thresholds: min 50% of + // 128K = 64_000, max 80% = 102_400. Turn anchors are only added when + // overMinLimit (and NOT overMaxLimit — that branch takes the + // context-limit anchors) — pre-fix (no fallback) overMinLimit was never + // true, so the turn-2 anchor assertion is the pre-fix discriminator. + const state = makeState(undefined) + const config = makeConfig(128_000) + config.compress.preserveRecentMessages = 20 + config.compress.minContextLimit = "50%" + config.compress.maxContextLimit = "80%" + const logger = new Logger(false) + + // Turn 1: 50_100 < 64_000 → below the min limit; baseline established. + const turn1: WithParts[] = [ + makeUserMessage("u1", "question one"), + makeAssistantWithTokens("a1", 50_000), + ] + injectCompressNudges(state, config, logger, turn1, {} as any) + assert.equal(state.nudges.lastPerMessageNudgeTokens, 50_100, "baseline = turn-1 currentTokens") + assert.equal(state.nudges.shouldInjectThisTurn, false, "no growth yet → no nudge") + assert.equal(state.nudges.turnNudgeAnchors.size, 0, "below min limit → no turn anchors") + + // Turn 2: 70_100 ≥ 64_000 (overMin) but ≤ 102_400 (not overMax) → the + // turn-anchor branch. The turn ends on a user message (a2 answered, u2b + // asks the next question), which is the shape that adds turn anchors. + // Growth 20_000 < nudgeGrowthTokens 50_000 (and < growth floor 22_500) + // → no nudge yet, but the anchors and baseline state are observable. + const turn2: WithParts[] = [ + makeUserMessage("u2", "question two"), + makeAssistantWithTokens("a2", 70_000), + makeUserMessage("u2b", "question two follow-up"), + ] + injectCompressNudges(state, config, logger, turn2, {} as any) + assert.equal( + state.nudges.turnNudgeAnchors.size, + 2, + "overMinLimit (fallback) must add turn anchors (the last user msg + last assistant)", + ) + assert.equal(state.nudges.contextLimitAnchors.size, 0, "not overMax → no context-limit anchors") + assert.equal(state.nudges.shouldInjectThisTurn, false, "growth below threshold → no nudge yet") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 50_100, "baseline stable (only compress resets)") + + // Turn 3: 130_100 > 102_400 → overMax (context-limit anchor branch); + // growth 80_000 from the 50_100 baseline ≥ 50_000 and ≥ growth floor + // 22_500 → nudge fires. + const turn3: WithParts[] = [ + makeUserMessage("u3", "question three"), + makeAssistantWithTokens("a3", 130_000), + makeUserMessage("u3b", "question three follow-up"), + ] + injectCompressNudges(state, config, logger, turn3, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "growth nudge fires past the fallback limits") + assert.equal(state.nudges.contextLimitAnchors.size, 1, "overMax adds a context-limit anchor") + assert.equal(state.nudges.lastNudgeShownTokens, 130_100, "lastNudgeShownTokens = currentTokens") + assert.equal( + state.nudges.lastPerMessageNudgeTokens, + 50_100, + "baseline survives the growth cycle (PR-207 invariant)", + ) +}) diff --git a/tests/model-switch-limits.test.ts b/tests/model-switch-limits.test.ts index 5b51198c..6a20f32f 100644 --- a/tests/model-switch-limits.test.ts +++ b/tests/model-switch-limits.test.ts @@ -17,7 +17,7 @@ import assert from "node:assert/strict" import test from "node:test" -import { mkdtempSync, readFileSync, rmSync } from "node:fs" +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs" import { join } from "node:path" import { tmpdir } from "node:os" import type { PluginConfig } from "../lib/config" @@ -162,6 +162,8 @@ async function runTransform(opts: { config?: PluginConfig }): Promise<{ text: string; state: SessionState }> { const tempDir = mkdtempSync(join(tmpdir(), "acp-model-switch-")) + const prevDataHome = process.env.XDG_DATA_HOME + const prevConfigHome = process.env.XDG_CONFIG_HOME process.env.XDG_DATA_HOME = tempDir process.env.XDG_CONFIG_HOME = tempDir @@ -204,6 +206,10 @@ async function runTransform(opts: { return { text: collectText(messages), state } } finally { rmSync(tempDir, { recursive: true, force: true }) + if (prevDataHome === undefined) delete process.env.XDG_DATA_HOME + else process.env.XDG_DATA_HOME = prevDataHome + if (prevConfigHome === undefined) delete process.env.XDG_CONFIG_HOME + else process.env.XDG_CONFIG_HOME = prevConfigHome } } @@ -427,6 +433,8 @@ test("catalog miss + provider config available: lazy hydration resolves the limi test("system.transform persists the limit so spawned processes resume with it (#346)", async () => { const tempDir = mkdtempSync(join(tmpdir(), "acp-persist-")) + const prevDataHome = process.env.XDG_DATA_HOME + const prevConfigHome = process.env.XDG_CONFIG_HOME process.env.XDG_DATA_HOME = tempDir process.env.XDG_CONFIG_HOME = tempDir @@ -448,16 +456,28 @@ test("system.transform persists the limit so spawned processes resume with it (# }, { system: ["base system prompt"] }, ) - // saveSessionState is fire-and-forget — give the write a tick to land. - await new Promise((resolve) => setTimeout(resolve, 100)) - + // saveSessionState is fire-and-forget — poll for the write to land + // (a fixed sleep would race slow CI). const file = join(tempDir, "opencode", "storage", "plugin", "acp", `${SID}.json`) - const persisted = JSON.parse(readFileSync(file, "utf8")) + let persisted: Record | undefined + const deadline = Date.now() + 2000 + while (!persisted && Date.now() < deadline) { + if (existsSync(file)) { + persisted = JSON.parse(readFileSync(file, "utf8")) + } else { + await new Promise((resolve) => setTimeout(resolve, 50)) + } + } + assert.ok(persisted, "state file must be written by the system hook") assert.equal(persisted.modelContextLimit, NEW_LIMIT) assert.equal(persisted.modelProviderID, PROVIDER) assert.equal(persisted.modelID, NEW_MODEL) } finally { rmSync(tempDir, { recursive: true, force: true }) + if (prevDataHome === undefined) delete process.env.XDG_DATA_HOME + else process.env.XDG_DATA_HOME = prevDataHome + if (prevConfigHome === undefined) delete process.env.XDG_CONFIG_HOME + else process.env.XDG_CONFIG_HOME = prevConfigHome } }) diff --git a/tests/registry-stub.ts b/tests/registry-stub.ts index fc7a61fb..4793f6e9 100644 --- a/tests/registry-stub.ts +++ b/tests/registry-stub.ts @@ -32,6 +32,7 @@ export function createTestRegistry(seedState: SessionState) { // Same implementation the real registry uses — composing the factory here // instead of hand-rolling a copy keeps the stub from drifting. const modelLimits = createModelLimitCatalog() + let hydratedOnce = false return { compressionTiming: sharedTiming, get size() { @@ -70,7 +71,8 @@ export function createTestRegistry(seedState: SessionState) { return modelLimits.resolve(providerId, modelId) }, // [FIX #346] Mirrors SessionStateRegistry.hydrateAndResolve — the - // messages transform calls it on a catalog miss. + // messages transform calls it on a catalog miss. Hydrates at most + // once per registry instance, like the real one. async hydrateAndResolve( client: unknown, providerId: string, @@ -80,7 +82,10 @@ export function createTestRegistry(seedState: SessionState) { if (existing !== undefined) { return existing } - await modelLimits.hydrateFromClient(client) + if (!hydratedOnce) { + hydratedOnce = true + await modelLimits.hydrateFromClient(client) + } return modelLimits.resolve(providerId, modelId) }, } diff --git a/tests/truncate-tools.test.ts b/tests/truncate-tools.test.ts index 3ff6d607..1ac0a706 100644 --- a/tests/truncate-tools.test.ts +++ b/tests/truncate-tools.test.ts @@ -147,6 +147,9 @@ function makeAssistantWithTokens(id: string, inputTokens: number, text = "ok"): } const LARGE_OUTPUT = "x".repeat(50000) +// [FIX #346] Smaller fixture for the new tests: 2201 tokens (comfortably +// above MIN_OUTPUT_TOKENS) but ~5x less tokenization work per message. +const MEDIUM_OUTPUT = "The quick brown fox jumps over the lazy dog. ".repeat(220) test("Truncation: does nothing when context is below threshold", () => { const state = makeState(200000) @@ -175,7 +178,8 @@ test("Truncation: truncates largest tool output at threshold", () => { messages.push(makeToolMessage(`msg-${i}`, LARGE_OUTPUT)) } messages.push(makeTextMessage("msg-user", "hello")) - // [FIX #346] 200_000 + output 100 = 200_100 ≥ threshold 200_000 + // [FIX #346] 200_000 + output 100 = 200_100 ≥ threshold 183_616 + // (= min(200_000, 200_000 − 16_384 output reserve)) messages.push(makeAssistantWithTokens("msg-asst", 200_000)) truncateLargeToolOutputs(state, config, noopLogger, messages) @@ -195,15 +199,17 @@ test("Truncation: truncates largest tool output at threshold", () => { }) test("Truncation: NEVER touches text messages or summaries", () => { - const state = makeState(1000) + // [FIX #346] Window must exceed OUTPUT_RESERVE_TOKENS (16384) so the + // overhead-aware threshold stays positive and truncation actually runs. + const state = makeState(200_000) const config = makeConfig() const messages: WithParts[] = [ makeToolMessage("msg-1", LARGE_OUTPUT), makeTextMessage("msg-2", "important summary text that must survive"), - makeAssistantWithTokens("msg-3", 1000, "another text message"), + makeAssistantWithTokens("msg-3", 200_000, "another text message"), makeTextMessage("msg-4", "user message"), - makeAssistantWithTokens("msg-5", 1000, "final"), + makeAssistantWithTokens("msg-5", 200_000, "final"), ] const originalTexts = messages.slice(1).map((m) => (m.parts[0] as any).text) @@ -217,14 +223,17 @@ test("Truncation: NEVER touches text messages or summaries", () => { }) test("Truncation: protects last 3 messages", () => { - const state = makeState(1000) + // [FIX #346] Window must exceed OUTPUT_RESERVE_TOKENS (16384) so the + // overhead-aware threshold stays positive and truncation actually runs. + const state = makeState(200_000) const config = makeConfig() const messages: WithParts[] = [] for (let i = 0; i < 10; i++) { messages.push(makeToolMessage(`msg-${i}`, LARGE_OUTPUT)) } - messages.push(makeAssistantWithTokens("msg-asst", 1000)) + // [FIX #346] 200_000 + output 100 = 200_100 ≥ threshold 183_616 + messages.push(makeAssistantWithTokens("msg-asst", 200_000)) truncateLargeToolOutputs(state, config, noopLogger, messages) @@ -242,7 +251,9 @@ test("Truncation: protects last 3 messages", () => { }) test("Truncation: skips already-truncated outputs", () => { - const state = makeState(1000) + // [FIX #346] Window must exceed OUTPUT_RESERVE_TOKENS (16384) so the + // overhead-aware threshold stays positive and truncation actually runs. + const state = makeState(200_000) const config = makeConfig() const alreadyTruncated = @@ -253,6 +264,8 @@ test("Truncation: skips already-truncated outputs", () => { makeToolMessage("msg-3", LARGE_OUTPUT), makeToolMessage("msg-4", "small"), makeToolMessage("msg-5", "small"), + // [FIX #346] 200_000 + output 100 = 200_100 ≥ threshold 183_616 + makeAssistantWithTokens("msg-asst", 200_000), ] truncateLargeToolOutputs(state, config, noopLogger, messages) @@ -262,35 +275,50 @@ test("Truncation: skips already-truncated outputs", () => { }) test("Truncation: skips small tool outputs", () => { - const state = makeState(1000) + // [FIX #346] Window must exceed OUTPUT_RESERVE_TOKENS (16384) so the + // overhead-aware threshold stays positive and truncation actually runs. + // A large output is included so the MIN_OUTPUT_TOKENS filter is + // exercised against real truncation, not a no-op. + const state = makeState(200_000) const config = makeConfig() const smallOutput = "small result" const messages: WithParts[] = [ - makeToolMessage("msg-1", smallOutput), + makeToolMessage("msg-1", LARGE_OUTPUT), makeToolMessage("msg-2", smallOutput), makeToolMessage("msg-3", smallOutput), makeToolMessage("msg-4", smallOutput), makeToolMessage("msg-5", smallOutput), + makeToolMessage("msg-6", smallOutput), + // [FIX #346] 200_000 + output 100 = 200_100 ≥ threshold 183_616 + makeAssistantWithTokens("msg-asst", 200_000), ] truncateLargeToolOutputs(state, config, noopLogger, messages) - for (let i = 0; i < messages.length; i++) { + for (let i = 1; i < messages.length - 1; i++) { const output = (messages[i]!.parts[0] as any).state.output assert.equal(output, smallOutput, `Small output ${i} should not be truncated`) } + const largeOutput = (messages[0]!.parts[0] as any).state.output + assert.ok( + largeOutput.includes("[truncated for context space"), + "the large output should have been truncated", + ) }) test("Truncation: no crash on empty messages", () => { - const state = makeState(1000) + const state = makeState(200_000) const config = makeConfig() truncateLargeToolOutputs(state, config, noopLogger, []) }) test("Truncation: no crash when no tool outputs exist", () => { - const state = makeState(1000) + // [FIX #346] Window must exceed OUTPUT_RESERVE_TOKENS (16384): a tiny + // window would trip the once-per-session overhead ERROR here and mask + // the "window too small" test's assertion later in this file. + const state = makeState(200_000) const config = makeConfig() const messages: WithParts[] = [ @@ -317,7 +345,8 @@ test("Truncation: preserves prefix and suffix of truncated output", () => { messages.push(makeToolMessage(`msg-${i}`, fullOutput)) } messages.push(makeTextMessage("msg-6", "text")) - // [FIX #346] 200_000 + output 100 = 200_100 ≥ threshold 200_000 + // [FIX #346] 200_000 + output 100 = 200_100 ≥ threshold 183_616 + // (= min(200_000, 200_000 − 16_384 output reserve)) messages.push(makeAssistantWithTokens("msg-7", 200_000)) truncateLargeToolOutputs(state, config, noopLogger, messages) @@ -336,7 +365,9 @@ test("Truncation: preserves prefix and suffix of truncated output", () => { }) test("Truncation equivalence: output never longer than input", () => { - const state = makeState(1000) + // [FIX #346] Window must exceed OUTPUT_RESERVE_TOKENS (16384) so the + // overhead-aware threshold stays positive and truncation actually runs. + const state = makeState(200_000) const config = makeConfig() const messages: WithParts[] = [] @@ -344,7 +375,8 @@ test("Truncation equivalence: output never longer than input", () => { messages.push(makeToolMessage(`msg-${i}`, "x".repeat(50000))) } messages.push(makeTextMessage("msg-11", "text")) - messages.push(makeAssistantWithTokens("msg-12", 1000)) + // [FIX #346] 200_000 + output 100 = 200_100 ≥ threshold 183_616 + messages.push(makeAssistantWithTokens("msg-12", 200_000)) const originalLengths = messages.map((m) => { const part = m.parts[0] as any @@ -378,7 +410,7 @@ test("production wall repro (#346): truncates when conversation + overhead excee const messages: WithParts[] = [makeTextMessage("u0", "start")] for (let i = 1; i <= 10; i++) { - messages.push(makeToolMessage(`t${i}`, LARGE_OUTPUT)) + messages.push(makeToolMessage(`t${i}`, MEDIUM_OUTPUT)) } messages.push(makeTextMessage("u1", "latest question")) // 229_379 + output 100 = 229_479 (the exact production token count). @@ -441,7 +473,7 @@ test("fallback limit drives truncation when model limit unknown (#346)", () => { const messages: WithParts[] = [makeTextMessage("u0", "start")] for (let i = 1; i <= 10; i++) { - messages.push(makeToolMessage(`t${i}`, LARGE_OUTPUT)) + messages.push(makeToolMessage(`t${i}`, MEDIUM_OUTPUT)) } messages.push(makeTextMessage("u1", "latest question")) // 200_000 + output 100 = 200_100 ≥ threshold min(200_000, 183_616)