From efdaecacc2382ea38111fccad6a33b01272f6b7d Mon Sep 17 00:00:00 2001 From: ework-agent Date: Sat, 29 Aug 2026 00:40:53 +0800 Subject: [PATCH 1/5] 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/5] 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) From ce0aca3fffc9205d7521237adb63da0ae1d2f1f4 Mon Sep 17 00:00:00 2001 From: ework-agent Date: Sat, 29 Aug 2026 00:59:07 +0800 Subject: [PATCH 3/5] fix: context budget guard + no-window warning (#347) A model with no declared context window (limit.context=0) disabled every percentage threshold in the plugin, so requests could grow past the backend's real window and be rejected with HTTP 400 - which opencode swallows as an empty exit-0 response (billion-context#317). - enforceContextBudget: deterministic prune-to-fit guard in messages.transform (truncate largest old tool outputs, then clear oldest) using the model window or an absolute maxContextLimit, reserving completionReserveTokens (default 32768) for completion - one-time WARN when the model reports no context window and the catalog has no entry, with actionable config guidance - new config: compress.completionReserveTokens (schema + docs) - 14 new unit tests --- CONFIGURATION.md | 5 + CONFIGURATION.zh-CN.md | 5 + dcp.schema.json | 9 +- devlog/2026-08-28_context-budget-guard/REQ.md | 131 +++++++ .../WORKLOG.md | 118 ++++++ lib/config.ts | 8 +- lib/hooks.ts | 19 + lib/messages/enforce-budget.ts | 237 ++++++++++++ lib/state/state.ts | 2 + lib/state/types.ts | 6 + tests/enforce-budget.test.ts | 348 ++++++++++++++++++ tmp/nested-fork-probe.mts | 114 ++++++ 12 files changed, 1000 insertions(+), 2 deletions(-) create mode 100644 devlog/2026-08-28_context-budget-guard/REQ.md create mode 100644 devlog/2026-08-28_context-budget-guard/WORKLOG.md create mode 100644 lib/messages/enforce-budget.ts create mode 100644 tests/enforce-budget.test.ts create mode 100644 tmp/nested-fork-probe.mts diff --git a/CONFIGURATION.md b/CONFIGURATION.md index ea6527ce..f3a619bc 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -377,6 +377,11 @@ In this example, for `anthropic/claude-sonnet-4-6`: the floor is 30%, the over-m } ``` Resolution: model-level `reasoning` > provider-level `reasoning` > global `compress.reasoning`, field by field (a field set at a deeper level overrides only that field). Provider/model IDs come from the current request's model identity. +#### `compress.completionReserveTokens` +- **Type:** `number` +- **Default:** `32768` +- **Status:** ACTIVE +- **Description:** Tokens reserved for the model's completion by the context-budget guard. The guard estimates the request's input size and, if it exceeds `window - completionReserveTokens`, deterministically truncates (then clears) old compressible tool outputs until it fits — summaries, protected tools, the first user message, and the last 3 messages are never touched. The default `32768` covers opencode's `32000` `max_tokens` fallback for models with no declared `limit.output`. The guard is a no-op unless a context window is known: the model's declared limit, or an absolute (number) `compress.maxContextLimit`. --- diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index 4282f459..58f21f99 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -377,6 +377,11 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): } ``` 解析顺序:model 级 `reasoning` > provider 级 `reasoning` > 全局 `compress.reasoning`,逐字段生效(深层只覆盖显式设置的字段)。provider/model id 取自当前请求的模型标识。 +#### `compress.completionReserveTokens` +- **类型:** `number` +- **默认值:** `32768` +- **状态:** ACTIVE +- **说明:** 上下文预算守卫为模型补全预留的 token 数。守卫估算请求输入大小,若超过 `window - completionReserveTokens`,则确定性地截断(随后清除)旧的可压缩工具输出直至达标——摘要、受保护工具、首条用户消息和最近 3 条消息永不被改动。默认 `32768` 覆盖 opencode 对未声明 `limit.output` 模型的 `32000` `max_tokens` 回退值。只有当上下文窗口已知时守卫才生效:模型声明的 limit,或绝对值(数字)`compress.maxContextLimit`。 --- diff --git a/dcp.schema.json b/dcp.schema.json index b00c2a3e..5bb8b0a4 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -625,6 +625,12 @@ "description": "Single-thinking size gate (chars): a closed-turn compress message's total reasoning length must exceed this to be dropped. Small thinkings are kept (not accumulated across messages). 0 drops any non-empty reasoning." } } + }, + "completionReserveTokens": { + "type": "number", + "default": 32768, + "minimum": 0, + "description": "Tokens reserved for the model's completion when the context-budget guard prunes tool outputs so the request fits the window. Default 32768 covers opencode's 32000 max_tokens fallback for models with no declared limit.output. The guard is a no-op unless the model's context window is known (declared limit or catalog entry); an absolute maxContextLimit is a soft nudge threshold and does not enable the guard." } }, "default": { @@ -653,7 +659,8 @@ "reasoning": { "drop": true, "threshold": 2048 - } + }, + "completionReserveTokens": 32768 } }, "gc": { diff --git a/devlog/2026-08-28_context-budget-guard/REQ.md b/devlog/2026-08-28_context-budget-guard/REQ.md new file mode 100644 index 00000000..4f2ccb73 --- /dev/null +++ b/devlog/2026-08-28_context-budget-guard/REQ.md @@ -0,0 +1,131 @@ +# REQ - Context budget guard + no-window warning + +- Task ID: `2026-08-28_context-budget-guard` +- Home Repo: `opencode-acp` +- Created: 2026-08-28 +- Status: InProgress +- Priority: P1 +- Owner: ework-daemon (agent) +- References: ranxianglei/billion-context#317, ranxianglei/opencode-acp#347 + +## 1. Background & Problem Statement + +- **Context**: Production incident (billion-context#317): long-lived headless sessions + (per-message resume via `opencode run --session `) fail fast on resume — model + process exits in ~5s with code 0 and no output, every retry reproduces, fresh session + works. Two cases: 230,529 and 37,178 `totalPruneTokens`, both `lastCompaction: 0`. +- **Current behavior (symptom)**: For a custom model with no declared context window + (opencode `/config/providers` reports `limit: {context: 0, output: 0}`), the plugin + never learns `modelContextLimit`. All percentage thresholds (min/max/emergency, GC) + silently disable, the only surviving protection (advisory 50K-growth nudge) is + model-cooperative, and opencode's `max_tokens` fallback (32,000 when `limit.output` + is unknown) is not accounted for. The request grows until the model backend rejects + it with HTTP 400 ("Requested token count exceeds the model's maximum context length + of 262144 tokens. You requested a total of 262527 tokens: 230527 tokens from the + input messages and 32000 tokens for the completion"). opencode swallows the 400: + `session.error` bus event, idle, exit 0, no output — the session is permanently + stuck (context only grows across retries). +- **Expected behavior**: + 1. When the model reports no context window and the catalog has no entry for it, + surface a loud, actionable warning (once per session) instead of failing blind. + 2. When the model reports a context window, never send a request whose estimated + input exceeds `window - completionReserve` — deterministically prune compressible + tool outputs (truncate to prefix+suffix, then clear oldest) until the request + fits. The guard ONLY enforces the model-reported window: an absolute + `compress.maxContextLimit` is a soft nudge threshold, not the backend's real + limit, and pruning to it would destroy context the backend would accept. +- **Impact**: Any deployment with a custom/self-hosted model lacking a `limit` entry + (sglang/vLLM local backends are the common case) with long sessions. Permanent + session loss (only a fresh session id recovers), no error surface for the user. + +## 2. Reproduction (if applicable) + +- **Environment**: + - Node: 22+ (plugin runtime; opencode 1.14.x host) + - OS/Arch: linux-x64 +- **Minimal reproduction steps**: + 1) Configure a custom model with no `limit` (e.g. sglang qwen via `vllm-qwen` + provider, `options.maxTokens` set but no `limit.context`). + 2) Run a long session where the model calls `compress` repeatedly so + `totalPruneTokens` grows (advisory nudges only, no hard gate). + 3) Resume with `opencode run --session "..."` once estimated input + + max_tokens (32,000 fallback) exceeds the backend's real window (262,144). + 4) Observe: 400 from backend, opencode exits 0 with no output, retries loop. +- **Relevant configuration**: + - `~/.config/opencode/opencode.json`: model without `limit`; `compaction.auto: false`. + - `acp.jsonc`: no absolute `compress.maxContextLimit` (percentages disabled). +- **Workaround (verified)**: add `"limit": {"context": 262144, "output": 16384}` to + the model definition in opencode.json (this is what enables the guard — + `modelContextLimit` becomes known); optionally an absolute + `compress.maxContextLimit` in acp.jsonc to make the advisory nudges proactive + (soft threshold only — the guard does not use it). + +## 3. Constraints & Non-Goals + +- **Constraints**: + - Backward compatibility: fully additive. Default behavior with a known model window + is unchanged (guard budget = window - reserve; existing GC truncation still runs + first). No persisted-state schema change (`noContextLimitWarned` is transient). + - Performance requirements: estimation reuses the existing Anthropic tokenizer path + (`getCurrentTokenUsage` + `countAllMessageTokens`); pruning loop only runs when + over budget. + - Resource limits: must never touch summaries (compress tool outputs), protected + tools, the first user message, or the last 3 messages (same protections as + `truncateLargeToolOutputs`). +- **Non-Goals** (explicitly out of scope): + - Fixing opencode's exit-0-on-400 (upstream issue; body drafted in #317). + - Learning the window from a 400 response (no plugin hook exposes response errors; + tracked as design note in #347). + - Changing nudge/GC trigger semantics. + +## 4. Acceptance Criteria (must be testable) + +- **Correctness**: + - [ ] `resolveContextWindow` returns `state.modelContextLimit` when set, and + undefined otherwise — deliberately NOT falling back to an absolute + `compress.maxContextLimit` (soft nudge threshold, not a backend window; + pruning to it regressed e2e-blocks-nudges by starving the nudge of its + compressible targets). + - [ ] `estimateWireTokens` = last-assistant reported usage + tokens of messages + after it; falls back to full content estimate + systemPromptTokens when no + assistant token data exists. + - [ ] `enforceContextBudget` is a no-op when the window is unknown or the estimate + is within budget. + - [ ] Over budget: largest old compressible tool outputs are truncated + (prefix+suffix, same marker as `truncateLargeToolOutputs`) until the estimate + fits; if truncation alone cannot fit, oldest outputs are cleared to the + standard placeholder. + - [ ] Protections hold: first user message, last 3 messages, `protectedTools`, + compress-tool outputs (summaries), already-cleared outputs. + - [ ] Idempotent: a second run after pruning does not modify messages further. + - [ ] Once-per-session WARN when the model reports no window and the catalog has no + entry, with actionable guidance (opencode.json `limit` or absolute + `compress.maxContextLimit`). +- **Performance / Stability**: + - [ ] No measurable overhead on the under-budget path beyond one + `getCurrentTokenUsage` scan (already computed elsewhere in the pipeline). +- **Regression**: + - [ ] New test file `tests/enforce-budget.test.ts` added and passing. + - [ ] Full suite green: `npm run build`, `npm run typecheck`, `npm test`. + +## 5. Proposed Approach (optional) + +- **Affected modules & entry files**: + - `lib/messages/enforce-budget.ts` (new) — window resolution, wire estimation, + deterministic prune-to-fit. + - `lib/hooks.ts` — call the guard right after `truncateLargeToolOutputs` in + `messages.transform`; warn-once after model-limit reconciliation. + - `lib/config.ts` — new optional `compress.completionReserveTokens` (default + 32768, covering opencode's 32,000 `max_tokens` fallback). + - `lib/state/types.ts` + `lib/state/state.ts` — transient + `noContextLimitWarned` flag (not persisted). + - `dcp.schema.json`, `CONFIGURATION.md`, `CONFIGURATION.zh-CN.md` — docs. + - `tests/enforce-budget.test.ts` (new). +- **Risks**: + - Over-pruning if the estimate overshoots (tokenizer vs backend tokenizer drift): + mitigated by the reserve margin (default 32768 >> drift) and by only pruning + compressible tool outputs, never user text or summaries. + - Estimate undercount when a backend counts `max_tokens` differently: reserve is + configurable via `completionReserveTokens`. +- **Rollback strategy**: Revert the single commit; all changes are additive and the + guard is a no-op without an absolute/known window. diff --git a/devlog/2026-08-28_context-budget-guard/WORKLOG.md b/devlog/2026-08-28_context-budget-guard/WORKLOG.md new file mode 100644 index 00000000..936ff2c9 --- /dev/null +++ b/devlog/2026-08-28_context-budget-guard/WORKLOG.md @@ -0,0 +1,118 @@ +# WORKLOG - Context Budget Guard + No-Window Warning + +- Task ID: `2026-08-28_context-budget-guard` +- Home Repo: `opencode-acp` +- Status: Done +- Updated: 2026-08-28 23:50 + +## 1. Summary + +- **What was done** (1–3 sentences): + Added a deterministic prune-to-fit guard (`enforceContextBudget`) to the + `messages.transform` pipeline and a once-per-session loud warning for models + that report no context window. Fixes ranxianglei/opencode-acp#347 + (billion-context#317): sessions whose input grew past the backend's real + window were rejected with HTTP 400, which opencode swallows as an empty + exit-0 response, permanently stumping the session with no error surface. +- **Why** (1–3 sentences): + When a custom model has no `limit` entry, `modelContextLimit` stays + undefined and every percentage threshold (min/max/emergency, GC) is + disabled; only advisory nudges remain, so input grows unbounded until the + backend 400s (230,527 input + 32,000 completion > 262,144 window in the + production case). The guard makes the known-window path deterministic; the + warning makes the unknown-window path loud and actionable. +- **Behavior / compatibility changes**: Yes — additive. With a known model + window, requests estimated above `window - completionReserveTokens` (default + 32,768) now have old compressible tool outputs truncated/cleared until they + fit (after the existing GC truncation). With an unknown window, a one-time + WARN with config guidance is logged. No persisted-state schema change + (`noContextLimitWarned` is transient). +- **Risk level**: Low — guard only prunes tool outputs already classified + compressible (same protections as `truncateLargeToolOutputs`), never user + text or summaries; all changes additive; full suite green. + +## 2. Change Log + +### Commits + +| Commit | Description | +|--------|-------------| +| `62976d4` | fix: context budget guard + no-window warning (#347) | + +### Key Files + +- `lib/messages/enforce-budget.ts` — new: `resolveContextWindow` (model + window only), `estimateWireTokens`, `enforceContextBudget` (phase 1 + truncate largest old tool outputs, phase 2 clear oldest to placeholder). +- `lib/hooks.ts` — guard call after `truncateLargeToolOutputs` in + `messages.transform`; warn-once after model-limit reconciliation. +- `lib/config.ts` — new optional `compress.completionReserveTokens` + (default applied at the consumer, `DEFAULT_COMPLETION_RESERVE_TOKENS`). +- `lib/state/types.ts`, `lib/state/state.ts` — transient + `noContextLimitWarned` flag (default/reset false, not persisted). +- `dcp.schema.json`, `CONFIGURATION.md`, `CONFIGURATION.zh-CN.md` — docs for + `completionReserveTokens`. +- `tests/enforce-budget.test.ts` — new: 14 tests covering window resolution, + estimation, no-op paths, phase 1/2 pruning, protections, idempotency, + over-budget warning. + +## 3. Design & Implementation Notes + +- **Entry point / key function**: + `enforceContextBudget(state, config, logger, messages)` in + `lib/messages/enforce-budget.ts`, called from `createChatMessageTransformHandler` + in `lib/hooks.ts` right after `truncateLargeToolOutputs`. +- **Key configuration items**: + - `compress.completionReserveTokens` (number, default 32768) — reserved for + the completion; covers opencode's 32,000 `max_tokens` fallback when + `limit.output` is 0/unknown. +- **Key logic explanation**: + - Window resolution uses ONLY `state.modelContextLimit`. An absolute + `compress.maxContextLimit` is deliberately NOT a fallback: it is a soft + nudge/compression threshold, not the backend's real limit. Pruning to a + guessed threshold destroys context the backend would accept and starves + the nudge of its compressible targets (regressed + e2e-blocks-nudges "compressible ranges injected into suffix message when + shouldNudge fires" during development: guard pruned the one large tool + output below the recommendation floor → `nothingToCompress` → no suffix). + Users with an unknown window get the loud one-time warning instead. + - Estimation: last assistant's reported usage (input + cacheRead + + cacheWrite + output + reasoning) + tokens of messages after it; fallback + (no assistant token data) = full content estimate + cached system prompt + tokens. + - Protections (same as `truncateLargeToolOutputs` plus): first user message, + last 3 messages, `protectedTools`, compress-tool outputs (summaries), + already-cleared outputs. Truncation reuses the same marker + (`[truncated for context space`) so the two mechanisms are idempotent + together. + - Same-turn caveat: after pruning, the estimate stays stale until the next + turn (assistant-reported usage); a re-run finds no candidates and logs a + "still over budget" warning — self-corrects on the next turn when the + model reports the shrunken input. + +## 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/enforce-budget.test.ts + +# Type check +npx tsc --noEmit +``` + +### Results + +- `npm run typecheck` — clean. +- `npm test` — 1043/1043 pass (14 new in `tests/enforce-budget.test.ts`). +- `npm run build` — success (dist/index.js 419.61 KB). +- Regression check: `tests/e2e-blocks-nudges.test.ts` 10/10 (was failing + during development while the guard used the absolute `maxContextLimit` as a + window; fixed by restricting the guard to the model-reported window). diff --git a/lib/config.ts b/lib/config.ts index 18a09f07..97e3e77c 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -104,6 +104,11 @@ export interface CompressConfig { * `getConfig()` always populates it via `DEFAULT_COMPRESS_REASONING`. */ reasoning?: CompressReasoningConfig + * Tokens reserved for the model's completion when enforcing the context + * budget guard (default: 32768 — covers opencode's 32000 max_tokens + * fallback for models with no declared limit.output). + */ + completionReserveTokens?: number } /** @@ -133,7 +138,7 @@ export interface CompressReasoningConfig { export const DEFAULT_COMPRESS_REASONING: Readonly = { drop: true, threshold: 2048, -} +}} export interface Commands { enabled: boolean @@ -560,6 +565,7 @@ export function mergeCompress( base.reasoning?.threshold ?? DEFAULT_COMPRESS_REASONING.threshold, }, + completionReserveTokens: override.completionReserveTokens ?? base.completionReserveTokens, } } diff --git a/lib/hooks.ts b/lib/hooks.ts index 0bfc075e..8b54bfae 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -29,6 +29,7 @@ import { filterMessages, filterMessagesInPlace } from "./messages/shape" import { getLastUserMessage } from "./messages/query" import { OUTPUT_RESERVE_TOKENS, truncateLargeToolOutputs } from "./messages/truncate-tools" import { resolveEffectiveContextLimit } from "./state/utils" +import { enforceContextBudget } from "./messages/enforce-budget" import { handleContextCommand, handleStatsCommand, @@ -270,6 +271,23 @@ export function createChatMessageTransformHandler( }) } await updatePerTurnState(state, logger, messages) + + if ( + state.modelContextLimit === undefined && + !state.noContextLimitWarned && + requestModel?.providerID && + requestModel?.modelID && + registry.resolveModelLimit(requestModel.providerID, requestModel.modelID) === undefined + ) { + state.noContextLimitWarned = true + logger.warn( + 'Model reports no context window and the catalog has no entry for it; all percentage thresholds (min/max/emergency, GC) and the context-budget guard are disabled. Set the model limit in opencode.json (e.g. "limit": {"context": 262144, "output": 16384}) or set an absolute compress.maxContextLimit in acp.jsonc.', + { + session: state.sessionId, + model: `${requestModel.providerID}/${requestModel.modelID}`, + }, + ) + } } syncCompressPermissionState(state, config, hostPermissions, output.messages) @@ -332,6 +350,7 @@ export function createChatMessageTransformHandler( const prePruneTokens = getCurrentTokenUsage(state, output.messages) prune(state, logger, config, output.messages) truncateLargeToolOutputs(state, config, logger, output.messages) + enforceContextBudget(state, config, logger, output.messages) hideConsumedCompressCalls(state, output.messages) assignMessageRefs(state, output.messages) const compressionPriorities = buildPriorityMap(config, state, output.messages) diff --git a/lib/messages/enforce-budget.ts b/lib/messages/enforce-budget.ts new file mode 100644 index 00000000..e6dd25f2 --- /dev/null +++ b/lib/messages/enforce-budget.ts @@ -0,0 +1,237 @@ +import { SessionState, WithParts } from "../state" +import type { PluginConfig } from "../config" +import { Logger } from "../logger" +import { + COMPACTED_TOOL_OUTPUT_PLACEHOLDER, + countAllMessageTokens, + countTokens, + extractCompletedToolOutput, + getCurrentTokenUsage, +} from "../token-utils" + +/** + * Default completion reserve in tokens. opencode falls back to max_tokens=32000 + * when the model's limit.output is unknown/0, so the reserve must cover that + * worst case (billion-context#317: 230527 input + 32000 completion > 262144 + * window → 400 → silent exit 0). + */ +export const DEFAULT_COMPLETION_RESERVE_TOKENS = 32768 + +const TRUNCATION_MARKER = "[truncated for context space" +const KEEP_PREFIX_CHARS = 2000 +const KEEP_SUFFIX_CHARS = 2000 +const PROTECT_RECENT_MESSAGES = 3 +const MIN_CLEAR_TOKENS = 200 + +export interface EnforceBudgetResult { + applied: boolean + window: number + reserve: number + budget: number + estimatedTokens: number + finalEstimate: number + truncatedCount: number + clearedCount: number +} + +/** + * Resolve the context window the budget guard enforces against. + * + * ONLY the model-reported window (state.modelContextLimit) is used. An + * absolute compress.maxContextLimit is deliberately NOT a fallback: it is a + * soft nudge/compression threshold, not the backend's real limit. Pruning to + * a guessed threshold destroys context the backend would have accepted (and + * starves the nudge of its compressible targets — see + * e2e-blocks-nudges "compressible ranges injected" regression). Users with + * an unknown window get the loud one-time warning from hooks.ts instead. + */ +export function resolveContextWindow(state: SessionState): number | undefined { + if (typeof state.modelContextLimit === "number" && state.modelContextLimit > 0) { + return state.modelContextLimit + } + return undefined +} + +/** + * Estimate the input token count of the request about to be sent. + * + * Primary: the last assistant message's reported usage (input + cacheRead + + * cacheWrite + output + reasoning — exactly what the model saw last turn plus + * what it produced, which is now history) + the tokens of every message after + * it (the new user message of this turn). + * + * Fallback (no assistant token data yet): full content estimate of all + * messages + the cached system prompt estimate. + */ +export function estimateWireTokens(state: SessionState, messages: WithParts[]): number { + const base = getCurrentTokenUsage(state, messages) + if (base > 0) { + let lastAssistant = -1 + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].info.role === "assistant") { + lastAssistant = i + break + } + } + let additions = 0 + for (let i = lastAssistant + 1; i < messages.length; i++) { + additions += countAllMessageTokens(messages[i]) + } + return base + additions + } + + let total = 0 + for (const m of messages) total += countAllMessageTokens(m) + return total + (state.systemPromptTokens ?? 0) +} + +/** + * Deterministic prune-to-fit: shrink compressible tool outputs until the + * estimated request fits `window - reserve`. Runs after + * truncateLargeToolOutputs (which only fires on a known modelContextLimit and + * only above the GC threshold), so it is the last line of defense against the + * model backend rejecting the request (HTTP 400 → opencode exit 0, no output). + * + * Phase 1: truncate the largest old tool outputs (prefix + suffix kept, same + * marker as truncateLargeToolOutputs for idempotency). + * Phase 2: if still over budget, clear the oldest remaining outputs to the + * standard cleared placeholder. + * + * Never touches: the first user message, the last 3 messages, protectedTools, + * compress-tool outputs (summaries), or already-cleared outputs. + */ +export function enforceContextBudget( + state: SessionState, + config: PluginConfig, + logger: Logger, + messages: WithParts[], +): EnforceBudgetResult | undefined { + const window = resolveContextWindow(state) + if (window === undefined) return undefined + + const reserve = config.compress?.completionReserveTokens ?? DEFAULT_COMPLETION_RESERVE_TOKENS + const budget = window - reserve + if (budget <= 0) return undefined + + const estimatedTokens = estimateWireTokens(state, messages) + if (estimatedTokens <= budget) { + return { + applied: false, + window, + reserve, + budget, + estimatedTokens, + finalEstimate: estimatedTokens, + truncatedCount: 0, + clearedCount: 0, + } + } + + const protectedIndex = messages.length - PROTECT_RECENT_MESSAGES + const protectedTools = new Set(config.compress?.protectedTools ?? []) + + const candidates: Array<{ part: any; content: string; tokens: number; index: number }> = [] + for (let mi = 0; mi < protectedIndex; mi++) { + if (mi === 0 && messages[mi].info.role === "user") continue + const msg = messages[mi] + const parts = Array.isArray(msg.parts) ? msg.parts : [] + for (const part of parts) { + if (part?.type !== "tool") continue + if (part.state?.status !== "completed") continue + if (part.tool === "compress") continue + if (protectedTools.has(part.tool)) continue + + const content = extractCompletedToolOutput(part) + if (content === undefined) continue + if (content === COMPACTED_TOOL_OUTPUT_PLACEHOLDER) continue + + const tokens = countTokens(content) + if (tokens <= 0) continue + candidates.push({ part, content, tokens, index: mi }) + } + } + + let saved = 0 + let truncatedCount = 0 + let clearedCount = 0 + + const truncatable = candidates + .filter( + (c) => + !c.content.includes(TRUNCATION_MARKER) && + c.content.length > KEEP_PREFIX_CHARS + KEEP_SUFFIX_CHARS, + ) + .sort((a, b) => b.tokens - a.tokens) + + for (const c of truncatable) { + if (estimatedTokens - saved <= budget) break + const prefix = c.content.slice(0, KEEP_PREFIX_CHARS) + const suffix = c.content.slice(-KEEP_SUFFIX_CHARS) + const truncated = + prefix + + `\n\n...${TRUNCATION_MARKER} — original ~${c.tokens} tokens]...\n\n` + + suffix + c.part.state.output = truncated + saved += c.tokens - countTokens(truncated) + truncatedCount++ + } + + if (estimatedTokens - saved > budget) { + const clearable = candidates + .filter((c) => { + const out = extractCompletedToolOutput(c.part) + return ( + out !== undefined && + out !== COMPACTED_TOOL_OUTPUT_PLACEHOLDER && + countTokens(out) > MIN_CLEAR_TOKENS + ) + }) + .sort((a, b) => a.index - b.index) + + for (const c of clearable) { + if (estimatedTokens - saved <= budget) break + const current = extractCompletedToolOutput(c.part) + if (current === undefined || current === COMPACTED_TOOL_OUTPUT_PLACEHOLDER) continue + c.part.state.output = COMPACTED_TOOL_OUTPUT_PLACEHOLDER + saved += countTokens(current) - countTokens(COMPACTED_TOOL_OUTPUT_PLACEHOLDER) + clearedCount++ + } + } + + const finalEstimate = Math.max(0, estimatedTokens - saved) + if (truncatedCount > 0 || clearedCount > 0) { + logger.warn("Context budget guard: pruned tool outputs to fit the request", { + session: state.sessionId, + estimatedTokens: Math.round(estimatedTokens), + budget, + window, + reserve, + truncatedCount, + clearedCount, + estimatedSavedTokens: Math.round(saved), + finalEstimate: Math.round(finalEstimate), + }) + } + if (finalEstimate > budget) { + logger.warn( + "Context budget guard: still over budget after pruning all compressible tool outputs; the request may be rejected by the model", + { + session: state.sessionId, + finalEstimate: Math.round(finalEstimate), + budget, + window, + }, + ) + } + + return { + applied: truncatedCount > 0 || clearedCount > 0, + window, + reserve, + budget, + estimatedTokens, + finalEstimate, + truncatedCount, + clearedCount, + } +} diff --git a/lib/state/state.ts b/lib/state/state.ts index cff1189b..926e6bf8 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -244,6 +244,7 @@ export function createSessionState(): SessionState { systemPromptTokens: undefined, storageDir: undefined, qualityGateRetryPending: false, + noContextLimitWarned: false, } } @@ -287,6 +288,7 @@ export function resetSessionState(state: SessionState): void { state.systemPromptTokens = undefined state.storageDir = undefined state.qualityGateRetryPending = false + state.noContextLimitWarned = false } export async function ensureSessionInitialized( diff --git a/lib/state/types.ts b/lib/state/types.ts index 0e8d43a0..509ebb02 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -166,4 +166,10 @@ export interface SessionState { * - Normal call (no acknowledgeRisk) → quality runs normally */ qualityGateRetryPending: boolean + /** + * Transient flag (NOT persisted): set to true after the "model reports no + * context window" warning has been emitted for this session, so the + * warning fires at most once per session per process. + */ + noContextLimitWarned: boolean } diff --git a/tests/enforce-budget.test.ts b/tests/enforce-budget.test.ts new file mode 100644 index 00000000..78fc15c3 --- /dev/null +++ b/tests/enforce-budget.test.ts @@ -0,0 +1,348 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import { createSessionState } from "../lib/state" +import type { SessionState, WithParts } from "../lib/state/types" +import type { PluginConfig } from "../lib/config" +import type { Logger } from "../lib/logger" +import { + DEFAULT_COMPLETION_RESERVE_TOKENS, + enforceContextBudget, + estimateWireTokens, + resolveContextWindow, +} from "../lib/messages/enforce-budget" + +const noopLogger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + child: () => noopLogger, +} as unknown as Logger + +const warnState: { warnings: string[] } = { warnings: [] } +const warnLogger: Logger = { + debug: () => {}, + info: () => {}, + warn: (msg: string) => { + warnState.warnings.push(msg) + }, + error: () => {}, + child: () => noopLogger, +} as unknown as Logger + +// ~45 chars / ~12 tokens of mixed prose so token counts track chars/4 +// regardless of tokenizer run-length behavior on repeated characters. +const FILLER = "The quick brown fox jumps over the lazy dog. ".repeat(20) + +function makeConfig(overrides: { + modelContextLimit?: number + maxContextLimit?: number | `${number}%` + reserve?: number + protectedTools?: string[] +} = {}): { config: PluginConfig; state: SessionState } { + const state = createSessionState() + state.sessionId = "session-budget" + if (overrides.modelContextLimit !== undefined) { + state.modelContextLimit = overrides.modelContextLimit + } + const config = { + enabled: true, + autoUpdate: false, + debug: false, + pruneNotification: "off" as const, + pruneNotificationType: "chat" as const, + commands: { enabled: true, protectedTools: [] as string[] }, + experimental: { allowSubAgents: false, customPrompts: false }, + protectedFilePatterns: [] as string[], + compress: { + permission: "allow" as const, + showCompression: false, + summaryBuffer: true, + maxContextLimit: 150000 as number | `${number}%`, + minContextLimit: 50000 as number | `${number}%`, + nudgeFrequency: 5, + iterationNudgeThreshold: 15, + nudgeForce: "soft" as const, + protectedTools: overrides.protectedTools ?? ([] as string[]), + protectTags: false, + protectUserMessages: false, + }, + strategies: { + deduplication: { enabled: true, protectedTools: [] as string[] }, + purgeErrors: { enabled: true, turns: 4, protectedTools: [] as string[] }, + }, + gc: { + algorithm: "truncate" as const, + promotionThreshold: 5, + maxBlockAge: 15, + maxOldGenSummaryLength: 3000, + majorGcThresholdPercent: "100%" as const, + batchCleanup: { + lowThreshold: "60%" as const, + highThreshold: "75%" as const, + forceThreshold: "90%" as const, + }, + }, + } as unknown as PluginConfig + if (overrides.maxContextLimit !== undefined) { + config.compress.maxContextLimit = overrides.maxContextLimit + } + if (overrides.reserve !== undefined) { + config.compress.completionReserveTokens = overrides.reserve + } + return { config, state } +} + +let idCounter = 0 +function nextId(prefix: string): string { + idCounter++ + return `${prefix}-${idCounter}` +} + +function makeUserText(id: string, text: string): WithParts { + return { + info: { + id, + role: "user", + sessionID: "session-budget", + time: { created: Date.now() }, + } as any, + parts: [{ type: "text", text }] as any, + } +} + +function makeAssistantText(id: string, text: string): WithParts { + return { + info: { + id, + role: "assistant", + sessionID: "session-budget", + time: { created: Date.now() }, + } as any, + parts: [{ type: "text", text }] as any, + } +} + +function makeAssistantWithTokens(id: string, input: number, output = 100): WithParts { + return { + info: { + id, + role: "assistant", + sessionID: "session-budget", + time: { created: Date.now() }, + tokens: { input, output, reasoning: 0, cache: { read: 0, write: 0 } }, + } as any, + parts: [{ type: "text", text: "ok" }] as any, + } +} + +function makeToolMessage(id: string, output: string, tool = "bash"): WithParts { + return { + info: { + id, + role: "user", + sessionID: "session-budget", + time: { created: Date.now() }, + } as any, + parts: [ + { + type: "tool", + tool, + state: { status: "completed", output, input: {}, time: {} }, + }, + ] as any, + } +} + +test("resolveContextWindow: uses modelContextLimit", () => { + const { state } = makeConfig({ modelContextLimit: 200000 }) + assert.equal(resolveContextWindow(state), 200000) +}) + +test("resolveContextWindow: no window without modelContextLimit (absolute maxContextLimit is a soft threshold, not a window)", () => { + const { state } = makeConfig({ maxContextLimit: 100000 }) + assert.equal(resolveContextWindow(state), undefined) +}) + +test("resolveContextWindow: no window with percentage maxContextLimit", () => { + const { state } = makeConfig({ maxContextLimit: "70%" }) + assert.equal(resolveContextWindow(state), undefined) +}) + +test("estimateWireTokens: base usage plus additions after last assistant", () => { + const { state } = makeConfig({ modelContextLimit: 200000 }) + const messages: WithParts[] = [ + makeUserText(nextId("u"), "hello"), + makeAssistantWithTokens(nextId("a"), 9000, 100), + makeUserText(nextId("u"), "follow-up question"), + ] + const est = estimateWireTokens(state, messages) + assert.ok(est >= 9100, `expected >= 9100, got ${est}`) + assert.ok(est < 9300, `expected < 9300, got ${est}`) +}) + +test("estimateWireTokens: fallback sums content plus system prompt", () => { + const { state } = makeConfig({ modelContextLimit: 200000 }) + state.systemPromptTokens = 500 + const messages: WithParts[] = [ + makeUserText(nextId("u"), FILLER.repeat(2)), + makeAssistantText(nextId("a"), FILLER.repeat(2)), + ] + const est = estimateWireTokens(state, messages) + assert.ok(est >= 500, `expected >= 500, got ${est}`) +}) + +test("enforceContextBudget: no-op when window unknown", () => { + const { config, state } = makeConfig({ maxContextLimit: "70%" }) + const messages: WithParts[] = [makeUserText(nextId("u"), FILLER.repeat(100))] + const result = enforceContextBudget(state, config, noopLogger, messages) + assert.equal(result, undefined) +}) + +test("enforceContextBudget: no-op when under budget", () => { + const { config, state } = makeConfig({ modelContextLimit: 200000, reserve: 32768 }) + const big = makeToolMessage(nextId("t"), FILLER.repeat(100)) + const messages: WithParts[] = [ + makeUserText(nextId("u"), "hello"), + big, + makeAssistantWithTokens(nextId("a"), 50000), + makeUserText(nextId("u"), "next"), + ] + const before = big.parts[0].state.output + const result = enforceContextBudget(state, config, noopLogger, messages) + assert.ok(result) + assert.equal(result!.applied, false) + assert.equal(big.parts[0].state.output, before) +}) + +test("enforceContextBudget: truncates largest old tool outputs until under budget", () => { + const { config, state } = makeConfig({ modelContextLimit: 100000, reserve: 1000 }) + const t1 = makeToolMessage(nextId("t"), FILLER.repeat(130)) + const messages: WithParts[] = [ + makeUserText(nextId("u"), "hello"), + t1, + makeAssistantWithTokens(nextId("a"), 5000), + makeUserText(nextId("u"), "mid"), + makeAssistantWithTokens(nextId("a2"), 99500), + makeUserText(nextId("u"), "next"), + ] + const result = enforceContextBudget(state, config, noopLogger, messages) + assert.ok(result) + assert.equal(result!.applied, true) + assert.ok(result!.truncatedCount >= 1, `expected truncation, got ${JSON.stringify(result)}`) + assert.ok(result!.finalEstimate <= result!.budget, "final estimate must fit budget") + assert.ok(String(t1.parts[0].state.output).includes("[truncated for context space")) +}) + +test("enforceContextBudget: never touches the last 3 messages or the first user message", () => { + const { config, state } = makeConfig({ modelContextLimit: 100000, reserve: 1000 }) + const firstUser = makeUserText(nextId("u"), "original task " + FILLER.repeat(60)) + const old = makeToolMessage(nextId("t"), FILLER.repeat(130)) + const recent = makeToolMessage(nextId("t"), FILLER.repeat(30)) + const messages: WithParts[] = [ + firstUser, + old, + makeAssistantWithTokens(nextId("a"), 5000), + makeUserText(nextId("u"), "mid"), + makeAssistantWithTokens(nextId("a2"), 99500), + makeUserText(nextId("u"), "next"), + recent, + ] + const firstUserBefore = firstUser.parts[0].text + const recentBefore = recent.parts[0].state.output + const result = enforceContextBudget(state, config, noopLogger, messages) + assert.ok(result) + assert.equal(firstUser.parts[0].text, firstUserBefore) + assert.equal(recent.parts[0].state.output, recentBefore) + assert.ok(String(old.parts[0].state.output).includes("[truncated for context space")) +}) + +test("enforceContextBudget: skips protected tools and compress summaries", () => { + const { config, state } = makeConfig({ + modelContextLimit: 100000, + reserve: 1000, + protectedTools: ["bash"], + }) + const other = makeToolMessage(nextId("t"), FILLER.repeat(130), "grep") + const protectedTool = makeToolMessage(nextId("t"), FILLER.repeat(130), "bash") + const summary = makeToolMessage(nextId("t"), FILLER.repeat(130), "compress") + const messages: WithParts[] = [ + makeUserText(nextId("u"), "hello"), + other, + protectedTool, + summary, + makeAssistantWithTokens(nextId("a"), 99500), + makeUserText(nextId("u"), "next"), + ] + const result = enforceContextBudget(state, config, noopLogger, messages) + assert.ok(result) + assert.equal(protectedTool.parts[0].state.output, FILLER.repeat(130)) + assert.equal(summary.parts[0].state.output, FILLER.repeat(130)) + assert.ok(String(other.parts[0].state.output).includes("[truncated for context space")) +}) + +test("enforceContextBudget: clears oldest outputs when truncation alone cannot fit", () => { + const { config, state } = makeConfig({ modelContextLimit: 100000, reserve: 1000 }) + const outputs: WithParts[] = [] + for (let i = 0; i < 10; i++) { + outputs.push(makeToolMessage(nextId("t"), FILLER.repeat(20))) + } + const messages: WithParts[] = [ + makeUserText(nextId("u"), "hello"), + ...outputs, + makeAssistantWithTokens(nextId("a"), 130000), + makeUserText(nextId("u"), "next"), + ] + const result = enforceContextBudget(state, config, noopLogger, messages) + assert.ok(result) + assert.equal(result!.applied, true) + assert.ok(result!.clearedCount >= 1, `expected clearing, got ${JSON.stringify(result)}`) + assert.ok(result!.finalEstimate <= result!.budget, "final estimate must fit budget") + assert.equal(outputs[0].parts[0].state.output, "[Old tool result content cleared]") +}) + +test("enforceContextBudget: idempotent on second run", () => { + const { config, state } = makeConfig({ modelContextLimit: 100000, reserve: 1000 }) + const t1 = makeToolMessage(nextId("t"), FILLER.repeat(130)) + const bigAssistant = makeAssistantWithTokens(nextId("a2"), 99500) + const messages: WithParts[] = [ + makeUserText(nextId("u"), "hello"), + t1, + makeAssistantWithTokens(nextId("a"), 5000), + makeUserText(nextId("u"), "mid"), + bigAssistant, + makeUserText(nextId("u"), "next"), + ] + const first = enforceContextBudget(state, config, noopLogger, messages) + assert.ok(first!.applied) + const afterFirst = String(t1.parts[0].state.output) + ;(bigAssistant.info as any).tokens.input = 70000 + const second = enforceContextBudget(state, config, noopLogger, messages) + assert.equal(afterFirst, String(t1.parts[0].state.output)) + assert.ok(second) + assert.equal(second!.applied, false) + assert.equal(second!.truncatedCount, 0) +}) + +test("enforceContextBudget: warns when still over budget after all pruning", () => { + const { config, state } = makeConfig({ modelContextLimit: 100000, reserve: 1000 }) + const hugeUser = makeUserText(nextId("u"), FILLER.repeat(400)) + const messages: WithParts[] = [ + hugeUser, + makeAssistantWithTokens(nextId("a"), 99500), + makeUserText(nextId("u"), "next"), + ] + warnState.warnings.length = 0 + const result = enforceContextBudget(state, config, warnLogger, messages) + assert.ok(result) + assert.equal(result!.applied, false) + assert.ok( + warnState.warnings.some((w) => w.includes("still over budget")), + `expected over-budget warning, got ${JSON.stringify(warnState.warnings)}`, + ) +}) + +test("enforceContextBudget: default reserve covers opencode's 32000 max_tokens fallback", () => { + assert.ok(DEFAULT_COMPLETION_RESERVE_TOKENS >= 32000) +}) diff --git a/tmp/nested-fork-probe.mts b/tmp/nested-fork-probe.mts new file mode 100644 index 00000000..4405f67d --- /dev/null +++ b/tmp/nested-fork-probe.mts @@ -0,0 +1,114 @@ +import "../tests/test-env" +import { recoverFromParentState } from "../lib/state/fork-transfer" +import { rebuildCompressionState } from "../lib/state/rebuild" +import { createSessionState } from "../lib/state/state" +import { saveSessionState } from "../lib/state/persistence" +import { Logger } from "../lib/logger" +import type { SessionState, WithParts } from "../lib/state/types" + +const logger = new Logger(false) +const PARENT_ID = "parent-session-375" +const FORK_ID = "fork-session-375" + +function makeUserMessage(id: string, text: string, created: number, sessionID: string): WithParts { + return { + info: { + id, sessionID, role: "user", agent: "assistant", + time: { created }, + model: { providerID: "test-provider", modelID: "test-model" }, + } as WithParts["info"], + parts: [{ type: "text", text, id: `${id}-p1`, sessionID, messageID: id }], + } +} + +function makeAssistantMessage(id: string, parts: any[], created: number, sessionID: string): WithParts { + return { + info: { + id, sessionID, role: "assistant", agent: "test", + time: { created }, parentID: "parent-1", + modelID: "test-model", providerID: "test-provider", mode: "normal", + path: { cwd: "/", root: "/" }, summary: false, cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } as WithParts["info"], + parts, + } +} + +const makeTextPart = (text: string): any => ({ type: "text", text }) + +function makeCompressPart(callId: string, input: any): any { + return { + type: "tool", tool: "compress", callID: callId, + state: { status: "completed", input, output: "Compressed messages into [Compressed conversation section]." }, + } +} + +function makeStrippedCompressPart(callId: string): any { + return { + type: "tool", tool: "compress", callID: callId, + state: { status: "completed", output: "Compressed messages into [Compressed conversation section]." }, + } +} + +function makeClient(parentMessages: WithParts[]): any { + return { session: { messages: async (_opts: any) => ({ data: parentMessages }) } } +} + +async function main() { + // Use the test file's buildConfig by dynamic import won't work (not exported). + // Inline minimal config matching tests/rebuild-parent.test.ts buildConfig(). + const { getConfig } = await import("../lib/config") + const cfg = getConfig(undefined, undefined) + + // Parent: b1 = m1-m4, then b2 = m1-m8 NESTED (consumes b1) + const input1 = { topic: "t1", content: [{ startId: "m00001", endId: "m00004", summary: "first range summary text" }] } + const input2 = { topic: "t2", content: [{ startId: "m00001", endId: "m00008", summary: "second nested summary covering all" }] } + + const parentState = createSessionState() + parentState.sessionId = PARENT_ID + parentState.isSubAgent = false + const parentMessages: WithParts[] = [ + makeUserMessage("p1", "hello", 1000, PARENT_ID), + makeAssistantMessage("p2", [makeTextPart("hi")], 1001, PARENT_ID), + makeUserMessage("p3", "do a task", 1002, PARENT_ID), + makeAssistantMessage("p4", [makeTextPart("doing it")], 1003, PARENT_ID), + makeAssistantMessage("p5", [makeCompressPart("call-1", input1)], 1004, PARENT_ID), + makeUserMessage("p6", "more", 1005, PARENT_ID), + makeAssistantMessage("p7", [makeTextPart("ok")], 1006, PARENT_ID), + makeUserMessage("p8", "done", 1007, PARENT_ID), + makeAssistantMessage("p9", [makeCompressPart("call-2", input2)], 1008, PARENT_ID), + ] + const rebuilt = rebuildCompressionState(parentState, parentMessages, cfg, logger) + console.log("parent rebuilt blocks:", rebuilt) + for (const [id, b] of parentState.prune.messages.blocksById) { + console.log(` parent b${id}: active=${b.active} deactivatedByBlockId=${b.deactivatedByBlockId ?? "-"} anchor=${b.anchorMessageId}`) + } + await saveSessionState(parentState, logger) + + // Fork copied p1..p5 only (stripped), then continued + const forkMessages: WithParts[] = [ + makeUserMessage("f1", "hello", 1000, FORK_ID), + makeAssistantMessage("f2", [makeTextPart("hi")], 1001, FORK_ID), + makeUserMessage("f3", "do a task", 1002, FORK_ID), + makeAssistantMessage("f4", [makeTextPart("doing it")], 1003, FORK_ID), + makeAssistantMessage("f5", [makeStrippedCompressPart("call-1")], 1004, FORK_ID), + makeUserMessage("f6", "new fork turn", 2000, FORK_ID), + ] + const client = makeClient(parentMessages) + const forkState = createSessionState() + forkState.sessionId = FORK_ID + forkState.isSubAgent = false + const recovered = await recoverFromParentState(client, forkState, forkMessages, PARENT_ID, logger) + console.log("recovered (ACTIVE blocks transferred):", recovered) + console.log("fork blocksById size:", forkState.prune.messages.blocksById.size) + for (const [id, b] of forkState.prune.messages.blocksById) { + console.log(` fork b${id}: active=${b.active} anchor=${b.anchorMessageId}`) + } + if (recovered === 0) { + console.log(">>> BUG CONFIRMED: parent-continued nested compression → transfer yields 0 → falls back to replay → 0 (runaway persists)") + } else { + console.log(">>> transfer works for nested case") + } +} + +main().catch((e) => { console.error(e); process.exit(1) }) From 3e264526326353604dff486656c1dba95cd59b4a Mon Sep 17 00:00:00 2001 From: ework-agent Date: Sat, 29 Aug 2026 03:09:33 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20review=20fixes=20=E2=80=94=20reserve?= =?UTF-8?q?=20validation,=20estimate=20alignment,=20shrink=20guard,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dual-agent review of the context budget guard found: - BLOCKER: compress.completionReserveTokens missing from VALID_CONFIG_KEYS → spurious "Unknown keys" toast for users setting the documented key - NaN reserve hazard: non-numeric config value made budget NaN, disabling every early-exit (guard would truncate all candidates) - estimateWireTokens undercount: anchored on last assistant by role while getCurrentTokenUsage anchors on last assistant WITH token data; also dropped the system prompt estimate when getCurrentTokenUsage fell back to content estimation - truncation could GROW content just over the 4000-char threshold (prefix/suffix overlap + marker line) - docs (schema, EN/CN, warning text) claimed an absolute compress.maxContextLimit enables the guard; it deliberately does not Tests: +9 (23 in enforce-budget.test.ts, 9 new in config-validation.test.ts); full suite 1052/1052. --- CONFIGURATION.md | 2 +- CONFIGURATION.zh-CN.md | 2 +- lib/config-validation.ts | 23 ++++++ lib/hooks.ts | 2 +- lib/messages/enforce-budget.ts | 51 +++++++++++--- tests/config-validation.test.ts | 33 +++++++++ tests/enforce-budget.test.ts | 119 ++++++++++++++++++++++++++++++-- 7 files changed, 212 insertions(+), 20 deletions(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index f3a619bc..82fcda8f 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -381,7 +381,7 @@ In this example, for `anthropic/claude-sonnet-4-6`: the floor is 30%, the over-m - **Type:** `number` - **Default:** `32768` - **Status:** ACTIVE -- **Description:** Tokens reserved for the model's completion by the context-budget guard. The guard estimates the request's input size and, if it exceeds `window - completionReserveTokens`, deterministically truncates (then clears) old compressible tool outputs until it fits — summaries, protected tools, the first user message, and the last 3 messages are never touched. The default `32768` covers opencode's `32000` `max_tokens` fallback for models with no declared `limit.output`. The guard is a no-op unless a context window is known: the model's declared limit, or an absolute (number) `compress.maxContextLimit`. +- **Description:** Tokens reserved for the model's completion by the context-budget guard. The guard estimates the request's input size and, if it exceeds `window - completionReserveTokens`, deterministically truncates (then clears) old compressible tool outputs until it fits — summaries, protected tools, the first user message, and the last 3 messages are never touched. The default `32768` covers opencode's `32000` `max_tokens` fallback for models with no declared `limit.output`. The guard is a no-op unless the model's context window is known (declared `limit.context` in opencode.json, or a catalog entry). An absolute (number) `compress.maxContextLimit` does **not** enable the guard — it is a soft nudge threshold, not the backend's real limit. --- diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index 58f21f99..7aa65ab3 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -381,7 +381,7 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): - **类型:** `number` - **默认值:** `32768` - **状态:** ACTIVE -- **说明:** 上下文预算守卫为模型补全预留的 token 数。守卫估算请求输入大小,若超过 `window - completionReserveTokens`,则确定性地截断(随后清除)旧的可压缩工具输出直至达标——摘要、受保护工具、首条用户消息和最近 3 条消息永不被改动。默认 `32768` 覆盖 opencode 对未声明 `limit.output` 模型的 `32000` `max_tokens` 回退值。只有当上下文窗口已知时守卫才生效:模型声明的 limit,或绝对值(数字)`compress.maxContextLimit`。 +- **说明:** 上下文预算守卫为模型补全预留的 token 数。守卫估算请求输入大小,若超过 `window - completionReserveTokens`,则确定性地截断(随后清除)旧的可压缩工具输出直至达标——摘要、受保护工具、首条用户消息和最近 3 条消息永不被改动。默认 `32768` 覆盖 opencode 对未声明 `limit.output` 模型的 `32000` `max_tokens` 回退值。只有当模型的上下文窗口已知时守卫才生效(opencode.json 中声明的 `limit.context`,或目录条目)。绝对值(数字)`compress.maxContextLimit` **不会**启用守卫——它是软性的提示阈值,而非后端的真实限制。 --- diff --git a/lib/config-validation.ts b/lib/config-validation.ts index da10604c..e3707d73 100644 --- a/lib/config-validation.ts +++ b/lib/config-validation.ts @@ -54,6 +54,7 @@ export const VALID_CONFIG_KEYS = new Set([ "compress.reasoning", "compress.reasoning.drop", "compress.reasoning.threshold", + "compress.completionReserveTokens", "gc", "gc.algorithm", "gc.promotionThreshold", @@ -587,6 +588,28 @@ export function validateConfigTypes(config: Record): ValidationErro } } + if ( + compress.completionReserveTokens !== undefined && + typeof compress.completionReserveTokens !== "number" + ) { + errors.push({ + key: "compress.completionReserveTokens", + expected: "number", + actual: typeof compress.completionReserveTokens, + }) + } + + if ( + typeof compress.completionReserveTokens === "number" && + compress.completionReserveTokens < 0 + ) { + errors.push({ + key: "compress.completionReserveTokens", + expected: "non-negative number (>= 0)", + actual: `${compress.completionReserveTokens}`, + }) + } + if ( typeof compress.iterationNudgeThreshold === "number" && compress.iterationNudgeThreshold < 1 diff --git a/lib/hooks.ts b/lib/hooks.ts index 8b54bfae..e5439915 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -281,7 +281,7 @@ export function createChatMessageTransformHandler( ) { state.noContextLimitWarned = true logger.warn( - 'Model reports no context window and the catalog has no entry for it; all percentage thresholds (min/max/emergency, GC) and the context-budget guard are disabled. Set the model limit in opencode.json (e.g. "limit": {"context": 262144, "output": 16384}) or set an absolute compress.maxContextLimit in acp.jsonc.', + 'Model reports no context window and the catalog has no entry for it; all percentage thresholds (min/max/emergency, GC) and the context-budget guard are disabled. Set the model limit in opencode.json (e.g. "limit": {"context": 262144, "output": 16384}) to enable them (also fixes the 32000 max_tokens fallback); an absolute compress.maxContextLimit in acp.jsonc only enables proactive nudges, not the guard.', { session: state.sessionId, model: `${requestModel.providerID}/${requestModel.modelID}`, diff --git a/lib/messages/enforce-budget.ts b/lib/messages/enforce-budget.ts index e6dd25f2..bba6d21c 100644 --- a/lib/messages/enforce-budget.ts +++ b/lib/messages/enforce-budget.ts @@ -1,4 +1,5 @@ import { SessionState, WithParts } from "../state" +import type { AssistantMessage } from "@opencode-ai/sdk/v2" import type { PluginConfig } from "../config" import { Logger } from "../logger" import { @@ -66,18 +67,30 @@ export function resolveContextWindow(state: SessionState): number | undefined { export function estimateWireTokens(state: SessionState, messages: WithParts[]): number { const base = getCurrentTokenUsage(state, messages) if (base > 0) { - let lastAssistant = -1 + // Align with getCurrentTokenUsage: base is the usage of the LAST + // assistant WITH token data (it skips tokenless aborted requests). + // Count additions after that same assistant — not after the last + // assistant by role. If the role-last assistant has no token data, + // the gap between the two (user message + aborted output) would + // otherwise be undercounted. + let baseAssistant = -1 for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].info.role === "assistant") { - lastAssistant = i - break - } + if (messages[i].info.role !== "assistant") continue + const tokens = (messages[i].info as AssistantMessage).tokens + if ((tokens?.input || 0) <= 0 && (tokens?.output || 0) <= 0) continue + baseAssistant = i + break } - let additions = 0 - for (let i = lastAssistant + 1; i < messages.length; i++) { - additions += countAllMessageTokens(messages[i]) + if (baseAssistant >= 0) { + let additions = 0 + for (let i = baseAssistant + 1; i < messages.length; i++) { + additions += countAllMessageTokens(messages[i]) + } + return base + additions } - return base + additions + // base > 0 without a token-data assistant means getCurrentTokenUsage + // itself fell back to content estimation — redo it here with the + // system prompt estimate included. } let total = 0 @@ -99,6 +112,10 @@ export function estimateWireTokens(state: SessionState, messages: WithParts[]): * * Never touches: the first user message, the last 3 messages, protectedTools, * compress-tool outputs (summaries), or already-cleared outputs. + * + * Note: the estimate is computed before message-ID tags and nudge text are + * injected later in the pipeline; those additions (a few hundred tokens) are + * absorbed by the completion reserve. */ export function enforceContextBudget( state: SessionState, @@ -109,7 +126,17 @@ export function enforceContextBudget( const window = resolveContextWindow(state) if (window === undefined) return undefined - const reserve = config.compress?.completionReserveTokens ?? DEFAULT_COMPLETION_RESERVE_TOKENS + // Config validation warns on bad values but does not block loading, so + // defend here: a non-numeric reserve would make `budget` NaN and every + // `<= budget` break condition false → the guard would truncate ALL + // candidates with no early exit. + const configuredReserve = config.compress?.completionReserveTokens + const reserve = + typeof configuredReserve === "number" && + Number.isFinite(configuredReserve) && + configuredReserve >= 0 + ? configuredReserve + : DEFAULT_COMPLETION_RESERVE_TOKENS const budget = window - reserve if (budget <= 0) return undefined @@ -171,6 +198,10 @@ export function enforceContextBudget( prefix + `\n\n...${TRUNCATION_MARKER} — original ~${c.tokens} tokens]...\n\n` + suffix + // Content just over the 4000-char threshold: prefix and suffix + // overlap and the marker line makes the "truncated" form LONGER. + // Skip it (phase 2 may still clear it) instead of growing it. + if (truncated.length >= c.content.length) continue c.part.state.output = truncated saved += c.tokens - countTokens(truncated) truncatedCount++ diff --git a/tests/config-validation.test.ts b/tests/config-validation.test.ts index cb9d8a00..fcb93578 100644 --- a/tests/config-validation.test.ts +++ b/tests/config-validation.test.ts @@ -232,6 +232,39 @@ test("validateConfigTypes catches wrong type for compress.preserveLastUserMessag assert.equal(result[0].expected, "boolean") }) +test("getInvalidConfigKeys accepts compress.completionReserveTokens", () => { + const result = getInvalidConfigKeys({ + compress: { completionReserveTokens: 32768 }, + }) + assert.deepEqual(result, []) +}) + +test("validateConfigTypes accepts numeric compress.completionReserveTokens", () => { + const result = validateConfigTypes({ + compress: { completionReserveTokens: 32768 }, + }) + assert.deepEqual(result, []) +}) + +test("validateConfigTypes catches wrong type for compress.completionReserveTokens", () => { + const result = validateConfigTypes({ + compress: { completionReserveTokens: "32768" }, + }) + assert.equal(result.length, 1) + assert.equal(result[0].key, "compress.completionReserveTokens") + assert.equal(result[0].expected, "number") + assert.equal(result[0].actual, "string") +}) + +test("validateConfigTypes rejects negative compress.completionReserveTokens", () => { + const result = validateConfigTypes({ + compress: { completionReserveTokens: -1 }, + }) + assert.equal(result.length, 1) + assert.equal(result[0].key, "compress.completionReserveTokens") + assert.equal(result[0].expected, "non-negative number (>= 0)") +}) + test("getInvalidConfigKeys accepts new preserveRecent* keys", () => { const result = getInvalidConfigKeys({ compress: { diff --git a/tests/enforce-budget.test.ts b/tests/enforce-budget.test.ts index 78fc15c3..89df10f2 100644 --- a/tests/enforce-budget.test.ts +++ b/tests/enforce-budget.test.ts @@ -11,6 +11,7 @@ import { estimateWireTokens, resolveContextWindow, } from "../lib/messages/enforce-budget" +import { countTokens } from "../lib/token-utils" const noopLogger: Logger = { debug: () => {}, @@ -31,8 +32,8 @@ const warnLogger: Logger = { child: () => noopLogger, } as unknown as Logger -// ~45 chars / ~12 tokens of mixed prose so token counts track chars/4 -// regardless of tokenizer run-length behavior on repeated characters. +// ~45 chars / ~10 tokens per sentence of mixed prose so token counts track +// chars/4 regardless of tokenizer run-length behavior on repeated characters. const FILLER = "The quick brown fox jumps over the lazy dog. ".repeat(20) function makeConfig(overrides: { @@ -185,12 +186,18 @@ test("estimateWireTokens: base usage plus additions after last assistant", () => test("estimateWireTokens: fallback sums content plus system prompt", () => { const { state } = makeConfig({ modelContextLimit: 200000 }) state.systemPromptTokens = 500 + const content = FILLER.repeat(2) const messages: WithParts[] = [ - makeUserText(nextId("u"), FILLER.repeat(2)), - makeAssistantText(nextId("a"), FILLER.repeat(2)), + makeUserText(nextId("u"), content), + makeAssistantText(nextId("a"), content), ] + // Single text-part messages count exactly countTokens(text), so the + // fallback must equal 2x content + system prompt (tiny tolerance for + // join overhead). + const expected = 500 + countTokens(content) * 2 const est = estimateWireTokens(state, messages) - assert.ok(est >= 500, `expected >= 500, got ${est}`) + assert.ok(est >= expected, `expected >= ${expected}, got ${est}`) + assert.ok(est <= expected + 5, `expected <= ${expected + 5}, got ${est}`) }) test("enforceContextBudget: no-op when window unknown", () => { @@ -284,19 +291,25 @@ test("enforceContextBudget: skips protected tools and compress summaries", () => test("enforceContextBudget: clears oldest outputs when truncation alone cannot fit", () => { const { config, state } = makeConfig({ modelContextLimit: 100000, reserve: 1000 }) + // 4050 chars: just over the 4000-char truncation threshold, so the + // truncated form (2000 + marker + 2000 ≈ 4062 chars) would be LONGER — + // phase 1 must skip every candidate (pure char-count fact, no BPE + // margin), forcing phase 2 (clearing) to do all the work. + const content = FILLER.repeat(4) + FILLER.slice(0, 450) const outputs: WithParts[] = [] for (let i = 0; i < 10; i++) { - outputs.push(makeToolMessage(nextId("t"), FILLER.repeat(20))) + outputs.push(makeToolMessage(nextId("t"), content)) } const messages: WithParts[] = [ makeUserText(nextId("u"), "hello"), ...outputs, - makeAssistantWithTokens(nextId("a"), 130000), + makeAssistantWithTokens(nextId("a"), 103000), makeUserText(nextId("u"), "next"), ] const result = enforceContextBudget(state, config, noopLogger, messages) assert.ok(result) assert.equal(result!.applied, true) + assert.equal(result!.truncatedCount, 0, `truncation must skip non-shrinking content: ${JSON.stringify(result)}`) assert.ok(result!.clearedCount >= 1, `expected clearing, got ${JSON.stringify(result)}`) assert.ok(result!.finalEstimate <= result!.budget, "final estimate must fit budget") assert.equal(outputs[0].parts[0].state.output, "[Old tool result content cleared]") @@ -346,3 +359,95 @@ test("enforceContextBudget: warns when still over budget after all pruning", () test("enforceContextBudget: default reserve covers opencode's 32000 max_tokens fallback", () => { assert.ok(DEFAULT_COMPLETION_RESERVE_TOKENS >= 32000) }) + +test("enforceContextBudget: no-op when window minus reserve is not positive", () => { + const { config, state } = makeConfig({ modelContextLimit: 10000, reserve: 32768 }) + const messages: WithParts[] = [ + makeUserText(nextId("u"), "hello"), + makeToolMessage(nextId("t"), FILLER.repeat(130)), + makeAssistantWithTokens(nextId("a"), 99500), + makeUserText(nextId("u"), "next"), + ] + const result = enforceContextBudget(state, config, noopLogger, messages) + assert.equal(result, undefined) +}) + +test("enforceContextBudget: no crash with fewer than 3 messages", () => { + const { config, state } = makeConfig({ modelContextLimit: 100000, reserve: 1000 }) + const t1 = makeToolMessage(nextId("t"), FILLER.repeat(130)) + const messages: WithParts[] = [t1, makeAssistantWithTokens(nextId("a"), 99500)] + const result = enforceContextBudget(state, config, noopLogger, messages) + assert.ok(result) + assert.equal(result!.applied, false) + assert.equal(result!.truncatedCount, 0) + assert.equal(result!.clearedCount, 0) + assert.equal(t1.parts[0].state.output, FILLER.repeat(130)) +}) + +test("enforceContextBudget: non-numeric reserve falls back to default", () => { + const { config, state } = makeConfig({ modelContextLimit: 100000 }) + ;(config.compress as any).completionReserveTokens = "oops" + const outputs: WithParts[] = [] + for (let i = 0; i < 3; i++) { + outputs.push(makeToolMessage(nextId("t"), FILLER.repeat(130))) + } + const messages: WithParts[] = [ + makeUserText(nextId("u"), "hello"), + ...outputs, + makeAssistantWithTokens(nextId("a"), 69000), + makeUserText(nextId("u"), "next"), + ] + const result = enforceContextBudget(state, config, noopLogger, messages) + assert.ok(result) + assert.equal(result!.reserve, DEFAULT_COMPLETION_RESERVE_TOKENS) + assert.equal(result!.budget, 100000 - DEFAULT_COMPLETION_RESERVE_TOKENS) + assert.equal(result!.applied, true) + // With a NaN budget (old code) every `<= budget` break was false and the + // guard never fit; with the fallback reserve it must stop once it fits. + assert.ok( + result!.finalEstimate <= result!.budget, + `expected final <= budget, got ${result!.finalEstimate} vs ${result!.budget}`, + ) +}) + +test("estimateWireTokens: counts the gap when the last assistant has no token data", () => { + const { state } = makeConfig({ modelContextLimit: 200000 }) + const messages: WithParts[] = [ + makeUserText(nextId("u"), "hello"), + makeAssistantWithTokens(nextId("a"), 9000, 100), + makeUserText(nextId("u"), FILLER.repeat(2)), + makeAssistantText(nextId("a2"), "ok"), + makeUserText(nextId("u"), "next"), + ] + // base 9100 + gap user message (~400) + "ok" + "next". A role-based scan + // would anchor at the tokenless "a2" and miss the gap message entirely. + const est = estimateWireTokens(state, messages) + assert.ok(est >= 9300, `expected >= 9300, got ${est}`) + assert.ok(est < 9800, `expected < 9800, got ${est}`) +}) + +test("enforceContextBudget: skips truncation that would grow the output, clears instead", () => { + const { config, state } = makeConfig({ modelContextLimit: 100000, reserve: 1000 }) + // 4050 chars: just over the 4000-char threshold, so prefix + marker + + // suffix (~4066 chars) would be LONGER than the original. + const content = FILLER.repeat(4) + FILLER.slice(0, 450) + const small = makeToolMessage(nextId("t"), content) + const messages: WithParts[] = [ + makeUserText(nextId("u"), "hello"), + small, + makeAssistantWithTokens(nextId("a"), 5000), + makeUserText(nextId("u"), "mid"), + makeAssistantWithTokens(nextId("a2"), 99500), + makeUserText(nextId("u"), "next"), + ] + const result = enforceContextBudget(state, config, noopLogger, messages) + assert.ok(result) + assert.equal(result!.applied, true) + assert.equal(result!.truncatedCount, 0, `truncation must skip non-shrinking content: ${JSON.stringify(result)}`) + assert.equal(result!.clearedCount, 1) + assert.ok( + result!.finalEstimate <= result!.budget, + `expected final <= budget, got ${result!.finalEstimate} vs ${result!.budget}`, + ) + assert.equal(small.parts[0].state.output, "[Old tool result content cleared]") +}) From fc7e921df8a7415ca1c235a16b7f53adbb49543e Mon Sep 17 00:00:00 2001 From: ework-agent Date: Sat, 29 Aug 2026 03:10:01 +0800 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20WORKLOG=20=E2=80=94=20dual-agent=20?= =?UTF-8?q?review=20findings=20and=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONFIGURATION.md | 238 +++++++++++------- CONFIGURATION.zh-CN.md | 238 +++++++++++------- dcp.schema.json | 50 +--- .../WORKLOG.md | 72 +++++- lib/config-validation.ts | 94 +++++-- lib/config.ts | 91 ++++--- lib/hooks.ts | 41 ++- 7 files changed, 525 insertions(+), 299 deletions(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 82fcda8f..76e513f5 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -8,11 +8,11 @@ Complete reference for all configurable parameters in Active Context Pruning (AC ACP reads config from up to three layers (later layers override earlier): -| Layer | Path | Scope | -|-------|------|-------| -| **Global** | `~/.config/opencode/acp.jsonc` | All sessions | -| **Config dir** | `$OPENCODE_CONFIG_DIR/acp.jsonc` | All sessions in this config dir | -| **Project** | `.opencode/acp.jsonc` (searched upward from cwd) | Current project only | +| Layer | Path | Scope | +| -------------- | ------------------------------------------------ | ------------------------------- | +| **Global** | `~/.config/opencode/acp.jsonc` | All sessions | +| **Config dir** | `$OPENCODE_CONFIG_DIR/acp.jsonc` | All sessions in this config dir | +| **Project** | `.opencode/acp.jsonc` (searched upward from cwd) | Current project only | > **Tip:** Add `"$schema": "https://raw.githubusercontent.com/ranxianglei/opencode-acp/master/dcp.schema.json"` for IDE autocompletion. @@ -26,8 +26,8 @@ ACP reads config from up to three layers (later layers override earlier): "compress": { "maxContextLimit": "70%", "minContextLimit": "70%", - "protectedTools": ["skill"] - } + "protectedTools": ["skill"], + }, } ``` @@ -42,58 +42,66 @@ Status legend: **ACTIVE** = currently used | **DEPRECATED** = kept for backward ### General #### `enabled` + - **Type:** `boolean` - **Default:** `true` - **Status:** ACTIVE - **Description:** Master switch. Set to `false` to completely disable ACP. #### `autoUpdate` + - **Type:** `boolean` - **Default:** `true` - **Status:** ACTIVE - **Description:** Automatically check for and install ACP updates on startup, tracking the dist-tag/spec the plugin was installed with (`opencode-acp@stable` follows the `stable` tag; range specs like `^1.14.0` follow `latest`). Version-locked specs are never updated. #### `debug` + - **Type:** `boolean` - **Default:** `false` - **Status:** ACTIVE - **Description:** Enable debug mode. When `true`, ACP sends a chat notification after each compression showing block details, and sets `logLevel` to `debug` (INFO/DEBUG logs plus per-request context snapshots at `~/.config/opencode/logs/acp/`). This flag overrides `logLevel` when set to `true`. #### `logLevel` + - **Type:** `"debug" | "info" | "warn" | "error" | "silent"` - **Default:** `"info"` - **Status:** ACTIVE - **Description:** File log verbosity for `~/.config/opencode/logs/acp/daily/.log`. Default `info` writes decision-level events by default (nudge decisions, transform summaries, auto-update checks, model switches). `warn`/`error` reduce output; `silent` disables file logging entirely; `debug` additionally enables per-request context snapshots and verbose dumps. Ignored when `debug: true`. #### `storagePath` + - **Type:** `string` - **Default:** unset — `$XDG_DATA_HOME/opencode/storage/plugin/acp` (i.e. `~/.local/share/opencode/storage/plugin/acp`) - **Status:** ACTIVE - **Description:** Directory where per-session state files (`{sessionId}.json` — compression blocks, nudge state, token stats) are persisted. Path semantics: - - Absolute path → used as-is - - `~` / `~/...` → expanded against the home directory - - Relative path → resolved against the project directory (where opencode was started) + - Absolute path → used as-is + - `~` / `~/...` → expanded against the home directory + - Relative path → resolved against the project directory (where opencode was started) - The directory is created automatically if it does not exist. If you set this option and the session's state file still exists at the default location, ACP logs a WARN (once per session) instead of migrating it — move the file manually if you want to keep the session history. + The directory is created automatically if it does not exist. If you set this option and the session's state file still exists at the default location, ACP logs a WARN (once per session) instead of migrating it — move the file manually if you want to keep the session history. #### `pruneNotification` + - **Type:** `"off" | "minimal" | "detailed"` - **Default:** `"off"` - **Status:** ACTIVE - **Description:** Compression notification verbosity. - - `"off"` — No notifications - - `"minimal"` — Brief one-line summary - - `"detailed"` — Full block details (topics, token counts, ranges) + - `"off"` — No notifications + - `"minimal"` — Brief one-line summary + - `"detailed"` — Full block details (topics, token counts, ranges) #### `pruneNotificationType` + - **Type:** `"chat" | "toast"` - **Default:** `"toast"` - **Status:** ACTIVE - **Description:** Delivery method for compression notifications. - - `"toast"` — Transient toast popup (recommended; non-blocking) - - `"chat"` — Inject as a chat message (may freeze session on providers that reject empty messages) + - `"toast"` — Transient toast popup (recommended; non-blocking) + - `"chat"` — Inject as a chat message (may freeze session on providers that reject empty messages) #### `protectedFilePatterns` + - **Type:** `string[]` - **Default:** `[]` - **Status:** ACTIVE @@ -106,12 +114,14 @@ Status legend: **ACTIVE** = currently used | **DEPRECATED** = kept for backward Controls ACP slash commands (`/acp context`, `/acp stats`, etc.). #### `commands.enabled` + - **Type:** `boolean` - **Default:** `true` - **Status:** ACTIVE - **Description:** Enable or disable all `/acp` slash commands. #### `commands.protectedTools` + - **Type:** `string[]` - **Default:** `["task", "skill", "todowrite", "todoread", "compress", "decompress", "batch", "plan_enter", "plan_exit", "write", "edit"]` - **Status:** ACTIVE @@ -134,6 +144,7 @@ Controls ACP slash commands (`/acp context`, `/acp stats`, etc.). Experimental features that may change or be removed. #### `experimental.customPrompts` + - **Type:** `boolean` - **Default:** `false` - **Status:** EXPERIMENTAL @@ -146,69 +157,80 @@ Experimental features that may change or be removed. Core compression behavior. #### `compress.permission` + - **Type:** `"ask" | "allow" | "deny"` - **Default:** `"allow"` - **Status:** ACTIVE - **Description:** Permission level for the `compress` tool. - - `"allow"` — Auto-approve compression calls - - `"ask"` — Prompt user before each compression - - `"deny"` — Block all compression calls + - `"allow"` — Auto-approve compression calls + - `"ask"` — Prompt user before each compression + - `"deny"` — Block all compression calls #### `compress.showCompression` + - **Type:** `boolean` - **Default:** `true` - **Status:** ACTIVE - **Description:** Show compression status indicators in the chat UI. #### `compress.summaryBuffer` + - **Type:** `boolean` - **Default:** `true` - **Status:** ACTIVE - **Description:** Inject summary buffer guidance into the system prompt, helping the model understand which blocks exist and their coverage. #### `compress.maxContextLimit` + - **Type:** `number | \`${number}%\`` - **Default:** `"80%"` - **Status:** ACTIVE - **Description:** Upper context usage threshold (as % of model context window or absolute tokens). When exceeded, ACP nudges the model to compress. Example: `"80%"` or `100000`. #### `compress.minContextLimit` + - **Type:** `number | \`${number}%\`` - **Default:** `"80%"` - **Status:** DEPRECATED - **Description:** **Deprecated — scheduled for removal.** Lower context usage threshold for turn/iteration reminder nudges. ACP stops injecting those reminders when usage drops below this level (or when the limit cannot be resolved to a concrete value, e.g. a `"X%"` limit with an unknown model context window). Growth nudges are governed separately by `minNudgeContextPercent`. Still honored until removed; when it is removed, the lower-bound gating for turn/iteration reminder nudges is retired along with it (new sessions should rely on the growth-nudge system). #### `compress.modelMaxLimits` + - **Type:** `Record` - **Default:** `undefined` - **Status:** ACTIVE - **Description:** Per-model override for `maxContextLimit`. Keyed by model ID. Example: `{"gpt-4": 80000, "claude-3-opus": "50%"}` #### `compress.modelMinLimits` + - **Type:** `Record` - **Default:** `undefined` - **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, 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` - **Default:** `5` - **Status:** ACTIVE - **Description:** Minimum number of turns between nudge injections. Prevents nagging the model every turn. #### `compress.minNudgeContextPercent` + - **Type:** `number` - **Default:** `5` - **Status:** ACTIVE - **Description:** Floor for growth-triggered nudges, as a percentage of the model context window: a growth nudge requires context usage at or above this percentage (in addition to the growth threshold). Over-max (`maxContextLimit`) and the 98% emergency-override nudges bypass the floor. If the model context window is unknown, the floor is unresolvable and growth nudges fall back to growth-only behavior. Turn/iteration reminder nudges are governed by `minContextLimit`, not this field. The default is deliberately low: with the default `nudgeGrowthTokens` (50K), a 5% floor stays inert for typical working cycles and only binds on very large (≥2M-class) windows — a higher default (e.g. 15%) would bind on ≥400K windows and shift every compress cycle's working range upward on large-window models. Set `0` to disable the floor entirely, or raise it (e.g. 15–30%) to keep growth nudges waiting until a larger share of the window is in use. Can be narrowed per provider / per model via [`compress.providers`](#compressproviders) (issue #344). #### `compress.providers` + - **Type:** `Record` where `ProviderOverrides = Partial & { models?: Record> }` (all fields optional at both levels) - **Default:** `undefined` - **Status:** ACTIVE @@ -230,43 +252,49 @@ Core compression behavior. "claude-sonnet-4-6": { "minNudgeContextPercent": 30, "maxContextLimit": "70%", - "nudgeGrowthTokens": 20000 - } - } - } - } - } + "nudgeGrowthTokens": 20000, + }, + }, + }, + }, + }, } ``` + In this example, for `anthropic/claude-sonnet-4-6`: the floor is 30%, the over-max band starts at 70% of the window instead of the global 55% (a larger working range before over-max nudges kick in), the growth threshold is 20K, and nudges use the `strong` tone (inherited from the provider level). Every other Anthropic model gets the 8% floor and `strong` tone but keeps the global band and 50K growth threshold; everything else uses the pure global values. Provider keys are provider ids and model keys are model ids (as reported by the active session, e.g. `anthropic`, `claude-sonnet-4-6`). #### `compress.nudgeGrowthTokens` + - **Type:** `number` - **Default:** `50000` (fixed) - **Status:** ACTIVE - **Description:** The nudge growth threshold. ACP nudges when context grows by this many tokens since the last nudge. The default is a fixed value, identical for all model context window sizes (was previously scaled as a percentage of the window — removed in v1.14.23 because it made small-window models nudge ~4× more often). #### `compress.toolOutputNudgeThreshold` + - **Type:** `number` - **Default:** `undefined` - **Status:** ACTIVE - **Description:** Token threshold for the tool-output-specific nudge. When tool outputs exceed this, a targeted nudge suggests compressing them. #### `compress.iterationNudgeThreshold` + - **Type:** `number` - **Default:** `15` - **Status:** ACTIVE - **Description:** Inject an iteration nudge when this many messages accumulate since the last user message (indicates long tool-use chains without user interaction). #### `compress.nudgeForce` + - **Type:** `"strong" | "soft"` - **Default:** `"soft"` - **Status:** ACTIVE - **Description:** Nudge tone. - - `"soft"` — Informational, lets the model decide - - `"strong"` — More urgent, emphasizes context overflow risk + - `"soft"` — Informational, lets the model decide + - `"strong"` — More urgent, emphasizes context overflow risk #### `compress.protectedTools` + - **Type:** `string[]` - **Default:** `["skill", "compress"]` - **Status:** ACTIVE @@ -275,91 +303,105 @@ In this example, for `anthropic/claude-sonnet-4-6`: the floor is 30%, the over-m > **Note:** `"compress"` is force-appended to this list regardless of user config — compression tool calls must never be lost. #### `compress.protectTags` + - **Type:** `boolean` - **Default:** `false` - **Status:** ACTIVE - **Description:** When `true`, `` tagged content is protected from compression. #### `compress.protectUserMessages` + - **Type:** `boolean` - **Default:** `false` - **Status:** ACTIVE - **Description:** When `true`, all user messages are protected from compression (not just the last one). #### `compress.maxSummaryLengthHard` + - **Type:** `number` - **Default:** `20000` - **Status:** ACTIVE - **Description:** Hard limit on summary length in characters. Compression calls with summaries exceeding this are rejected. #### `compress.minCompressRange` + - **Type:** `number` - **Default:** `5000` - **Status:** ACTIVE - **Description:** Minimum estimated tokens in a compression range. Ranges smaller than this are filtered out from recommendations (not worth compressing). #### `compress.minNudgeGrowthRatio` + - **Type:** `number` - **Default:** `0.45` - **Status:** ACTIVE - **Description:** Ratio of `nudgeGrowthTokens` used to calculate the nudge growth floor. Higher value = less frequent nudges. #### `compress.minNudgeGrowthFloor` + - **Type:** `number` - **Default:** `5000` - **Status:** ACTIVE - **Description:** Minimum nudge growth threshold in tokens. The actual threshold is `max(this, minNudgeGrowthRatio × nudgeGrowthTokens)`. #### `compress.emergencyThresholdPercent` + - **Type:** `number | \`${number}%\`` - **Default:** `"98%"` - **Status:** ACTIVE - **Description:** Context usage threshold for "emergency" mode. When exceeded, ACP overrides all protection filters and forcefully nudges the model to compress immediately. #### `compress.maxVisibleSegments` + - **Type:** `number` - **Default:** `50` - **Status:** ACTIVE - **Description:** Maximum number of visible context segments to display in the system prompt's segment guidance. #### `compress.keepEmbedMaxChars` + - **Type:** `number` - **Default:** `2000` - **Status:** ACTIVE - **Description:** Maximum characters to embed per message when using `[[KEEP:mNNNNN]]` markers in compression summaries. #### `compress.lastSegmentSoftBlock` + - **Type:** `boolean` - **Default:** `true` - **Status:** ACTIVE - **Description:** When `true`, the last visible segment (most recent messages) is treated as a soft-block — excluded from compression recommendations but can be overridden with `dangerous: true`. #### `compress.preserveRecentMessages` + - **Type:** `number` - **Default:** `5` - **Status:** ACTIVE - **Description:** Number of most recent messages to protect from compression. These messages are soft-filtered from compressible ranges. Set to `0` to disable. #### `compress.preserveRecentTokens` + - **Type:** `number` - **Default:** `5000` - **Status:** ACTIVE - **Description:** Token budget for recent-message protection. In addition to the last N messages, ACP also protects messages within this token budget (expanding backward from the most recent message). Set to `0` to disable. #### `compress.preserveLastUserMessage` + - **Type:** `boolean` - **Default:** `true` - **Status:** ACTIVE - **Description:** Always protect the most recent user message from compression, regardless of `preserveRecentMessages` or `preserveRecentTokens`. #### `compress.reasoning` + - **Type:** `object { drop?: boolean; threshold?: number }` - **Default:** `{ "drop": true, "threshold": 2048 }` - **Status:** ACTIVE (#368) - **Description:** Config for dropping oversized reasoning (thinking) parts from historical `compress` tool calls. `compress` calls are hard-exempt from compression, so their thinking rides along every request as an unreclaimable context floor. This pass removes `reasoning` parts at request time (persisted history is never modified) from closed-turn compress messages whose total reasoning length exceeds `threshold`. The active round (from the last genuine user message onward) is never touched. - **Fields:** - - `drop` (`boolean`, default `true`) — master switch; `false` disables the whole pass. - - `threshold` (`number`, chars, default `2048`) — single-thinking size gate: the message's total reasoning length must **exceed** this to be dropped. Small thinkings are kept; lengths are NOT accumulated across messages. `0` drops any non-empty reasoning (only zero-length reasoning survives). + - `drop` (`boolean`, default `true`) — master switch; `false` disables the whole pass. + - `threshold` (`number`, chars, default `2048`) — single-thinking size gate: the message's total reasoning length must **exceed** this to be dropped. Small thinkings are kept; lengths are NOT accumulated across messages. `0` drops any non-empty reasoning (only zero-length reasoning survives). - **Per-provider/model overrides (field-wise, via the `compress.providers` cascade):** ```jsonc @@ -370,14 +412,17 @@ In this example, for `anthropic/claude-sonnet-4-6`: the floor is 30%, the over-m "my-gateway": { "reasoning": { "drop": false } }, "anthropic": { "reasoning": { "threshold": 8000 }, - "models": { "claude-opus-4-5": { "reasoning": { "threshold": 16000 } } } - } - } - } + "models": { "claude-opus-4-5": { "reasoning": { "threshold": 16000 } } }, + }, + }, + }, } ``` - Resolution: model-level `reasoning` > provider-level `reasoning` > global `compress.reasoning`, field by field (a field set at a deeper level overrides only that field). Provider/model IDs come from the current request's model identity. + +Resolution: model-level `reasoning` > provider-level `reasoning` > global `compress.reasoning`, field by field (a field set at a deeper level overrides only that field). Provider/model IDs come from the current request's model identity. + #### `compress.completionReserveTokens` + - **Type:** `number` - **Default:** `32768` - **Status:** ACTIVE @@ -390,30 +435,35 @@ In this example, for `anthropic/claude-sonnet-4-6`: the floor is 30%, the over-m > **Note:** The GC truncation module (`gc/truncate.ts`) was removed in v1.14.4. The remaining `gc` fields are used for block generation tracking and batch cleanup (block merging). Old config files with these fields continue to work. #### `gc.algorithm` + - **Type:** `"truncate"` - **Default:** `"truncate"` - **Status:** DEPRECATED - **Description:** Historically selected the GC algorithm. Only `"truncate"` was ever implemented. Now a no-op — kept for config compatibility. #### `gc.promotionThreshold` + - **Type:** `number` - **Default:** `5` - **Status:** ACTIVE - **Description:** Number of message-transform cycles a compression block must survive before being promoted from `"young"` to `"old"` generation. Old-gen blocks are eligible for batch merging by `gc/merge.ts`. #### `gc.maxBlockAge` + - **Type:** `number` - **Default:** `9007199254740991` (`Number.MAX_SAFE_INTEGER`) - **Status:** DEPRECATED - **Description:** Historically controlled age-based block deactivation. Set to infinity — effectively disabled. Kept for config compatibility. #### `gc.maxOldGenSummaryLength` + - **Type:** `number` - **Default:** `3000` - **Status:** ACTIVE - **Description:** Maximum length (in characters) for a merged summary when batch cleanup consolidates multiple old-gen blocks into a higher-tier block. #### `gc.majorGcThresholdPercent` + - **Type:** `number | \`${number}%\`` - **Default:** `"100%"` - **Status:** ACTIVE @@ -424,18 +474,21 @@ In this example, for `anthropic/claude-sonnet-4-6`: the floor is 30%, the over-m Batch cleanup consolidates multiple old-gen blocks into higher-tier blocks. ##### `gc.batchCleanup.lowThreshold` + - **Type:** `number | \`${number}%\`` - **Default:** `"55%"` - **Status:** ACTIVE - **Description:** Context usage threshold for low-priority batch cleanup. At this level, batch cleanup considers merging old-gen blocks. ##### `gc.batchCleanup.highThreshold` + - **Type:** `number | \`${number}%\`` - **Default:** `"75%"` - **Status:** ACTIVE - **Description:** Medium-priority batch cleanup threshold. ##### `gc.batchCleanup.forceThreshold` + - **Type:** `number | \`${number}%\`` - **Default:** `"90%"` - **Status:** ACTIVE @@ -448,30 +501,33 @@ Batch cleanup consolidates multiple old-gen blocks into higher-tier blocks. Post-compression quality evaluation. Runs after each compression to verify summary quality. #### `qualityGate.enabled` + - **Type:** `boolean` - **Default:** `false` - **Status:** ACTIVE - **Description:** Enable post-compression quality evaluation. When `true`, ACP evaluates each compression summary against quality metrics. Failures are logged but do not block compression (non-blocking). #### `qualityGate.algorithm` + - **Type:** `string` - **Default:** `"rouge-recall-v1"` - **Status:** ACTIVE - **Description:** Quality gate algorithm to use. Currently only `"rouge-recall-v1"` is available. #### `qualityGate.algorithms` + - **Type:** `object` - **Status:** ACTIVE - **Description:** Algorithm-specific parameters. See below. ##### `qualityGate.algorithms.rouge-recall-v1` -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `layer1MinChars` | number | 200 | Minimum summary length in characters | -| `layer1MinRetentionPct` | number | 5.0 | Minimum content retention percentage | -| `layer2MaxRougeF1` | number | 0.05 | Maximum ROUGE-1 F1 score for "too similar" detection | -| `layer2MaxTop20Recall` | number | 0.20 | Maximum top-20 keyword recall for quality check | +| Parameter | Type | Default | Description | +| ----------------------- | ------ | ------- | ---------------------------------------------------- | +| `layer1MinChars` | number | 200 | Minimum summary length in characters | +| `layer1MinRetentionPct` | number | 5.0 | Minimum content retention percentage | +| `layer2MaxRougeF1` | number | 0.05 | Maximum ROUGE-1 F1 score for "too similar" detection | +| `layer2MaxTop20Recall` | number | 0.20 | Maximum top-20 keyword recall for quality check | --- @@ -481,21 +537,23 @@ Message filters strip or deduplicate third-party plugin injections (e.g. oh-my-o Since v1.14.8, ACP ships five built-in oh-my-opencode (OMO) filters, all enabled by default: -| Filter | Version | Behavior | -|--------|---------|----------| -| `omo-system-reminder` | 1.3.0 | Keeps the last 2 OMO `` messages; for older ones, strips the `` blocks and `` markers but preserves your actual user content | -| `omo-context` | 1.0.0 | Keeps only the latest OMO `[CONTEXT]` injection; drops earlier duplicates | -| `omo-task-directive` | 1.0.0 | Keeps only the latest OMO `TASK:` / `## TASK` directive; drops earlier ones | -| `omo-todo-continuation` | 1.0.0 | Keeps only the latest OMO TODO CONTINUATION directive; drops earlier ones | -| `omo-mode-injection` | 1.1.0 | Strips leading mode blocks (``, `[search-mode]`, …) and preserves user content | +| Filter | Version | Behavior | +| ----------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `omo-system-reminder` | 1.3.0 | Keeps the last 2 OMO `` messages; for older ones, strips the `` blocks and `` markers but preserves your actual user content | +| `omo-context` | 1.0.0 | Keeps only the latest OMO `[CONTEXT]` injection; drops earlier duplicates | +| `omo-task-directive` | 1.0.0 | Keeps only the latest OMO `TASK:` / `## TASK` directive; drops earlier ones | +| `omo-todo-continuation` | 1.0.0 | Keeps only the latest OMO TODO CONTINUATION directive; drops earlier ones | +| `omo-mode-injection` | 1.1.0 | Strips leading mode blocks (``, `[search-mode]`, …) and preserves user content | #### `messageFilters.enabled` + - **Type:** `boolean` - **Default:** `true` - **Status:** ACTIVE - **Description:** Master switch. When `false`, no filters run. #### `messageFilters.filters` + - **Type:** `object` — `Record` - **Default:** all built-in filters enabled - **Status:** ACTIVE @@ -512,9 +570,9 @@ Example: "omo-context": { "enabled": true }, "omo-task-directive": { "enabled": true }, "omo-todo-continuation": { "enabled": true }, - "omo-mode-injection": { "enabled": true } - } - } + "omo-mode-injection": { "enabled": true }, + }, + }, } ``` @@ -523,6 +581,7 @@ Example: ## Common Config Recipes ### Aggressive compression (maximize context savings) + ```jsonc { "compress": { @@ -530,12 +589,13 @@ Example: "minContextLimit": "35%", "preserveRecentMessages": 3, "preserveRecentTokens": 2000, - "nudgeFrequency": 3 - } + "nudgeFrequency": 3, + }, } ``` ### Conservative compression (minimize information loss) + ```jsonc { "compress": { @@ -544,35 +604,37 @@ Example: "preserveRecentMessages": 15, "preserveRecentTokens": 10000, "nudgeFrequency": 8, - "protectedTools": ["skill", "bash", "read", "grep", "glob"] - } + "protectedTools": ["skill", "bash", "read", "grep", "glob"], + }, } ``` ### Disable automatic compression entirely + ```jsonc -{ -} +{} ``` ### Per-model context limits + ```jsonc { "compress": { "modelMaxLimits": { "gpt-4o": 80000, "claude-3.5-sonnet": "60%", - "gemini-1.5-pro": 150000 + "gemini-1.5-pro": 150000, }, "modelMinLimits": { "gpt-4o": 60000, - "claude-3.5-sonnet": "50%" - } - } + "claude-3.5-sonnet": "50%", + }, + }, } ``` ### Per-model growth-nudge floor (nested providers.models) + ```jsonc { "compress": { @@ -582,17 +644,20 @@ Example: "minNudgeContextPercent": 8, "models": { "claude-sonnet-4-6": { "minNudgeContextPercent": 30 }, - "claude-haiku-4-5": { "minNudgeContextPercent": 0 } - } - } - } - } + "claude-haiku-4-5": { "minNudgeContextPercent": 0 }, + }, + }, + }, + }, } ``` + Floors resolve as model > provider > global (field-by-field). `0` disables the floor for that model — useful for small-window models where the growth threshold alone is the right signal. ### Per-model tuning of any compress field (nested providers.models) + The same cascade works for every tunable field, not just the floor — e.g. give one heavy model a tighter growth threshold and a lower over-max band while its siblings keep the global profile: + ```jsonc { "compress": { @@ -601,17 +666,19 @@ The same cascade works for every tunable field, not just the floor — e.g. give "models": { "claude-sonnet-4-6": { "nudgeGrowthTokens": 20000, - "maxContextLimit": "40%" - } - } - } - } - } + "maxContextLimit": "40%", + }, + }, + }, + }, + }, } ``` + See the [`compress.providers`](#compressproviders) reference for the full overridable field list. ### Protect sensitive files + ```jsonc { "protectedFilePatterns": [ @@ -619,13 +686,15 @@ See the [`compress.providers`](#compressproviders) reference for the full overri "**/*.pem", "**/*.key", "**/credentials.json", - "**/secrets.*" - ] + "**/secrets.*", + ], } ``` ### Tune or disable oh-my-opencode (OMO) injection filters + The five built-in OMO filters are on by default (see [`messageFilters`](#messagefilters)). Keep more recent system-reminders, disable a single filter, or turn the whole subsystem off: + ```jsonc { "messageFilters": { @@ -634,15 +703,16 @@ The five built-in OMO filters are on by default (see [`messageFilters`](#message // keep the last 3 OMO system-reminders instead of 2 "omo-system-reminder": { "enabled": true, "keepLast": 3 }, // disable one filter, keep the rest - "omo-task-directive": { "enabled": false } - } - } + "omo-task-directive": { "enabled": false }, + }, + }, } ``` + ```jsonc { // turn all message filters off - "messageFilters": { "enabled": false } + "messageFilters": { "enabled": false }, } ``` @@ -651,7 +721,7 @@ The five built-in OMO filters are on by default (see [`messageFilters`](#message ```jsonc { // Absolute path, ~ expansion, or project-relative - "storagePath": "~/data/acp-state" + "storagePath": "~/data/acp-state", } ``` @@ -661,12 +731,12 @@ The five built-in OMO filters are on by default (see [`messageFilters`](#message These parameters existed in older versions and have been removed. Config files containing them will show a warning toast but continue to work. -| Parameter | Version Removed | Replacement | -|-----------|----------------|-------------| -| `strategies.deduplication.*` | PR #206 | Compression tool handles duplicates | -| `strategies.purgeErrors.*` | PR #206 | Compression tool handles error pruning | -| `compress.automaticStrategies` | PR #206 | Always-on; no config needed | -| `state.prune.tools` | PR #206 | Internal only; no config | +| Parameter | Version Removed | Replacement | +| ------------------------------ | --------------- | -------------------------------------- | +| `strategies.deduplication.*` | PR #206 | Compression tool handles duplicates | +| `strategies.purgeErrors.*` | PR #206 | Compression tool handles error pruning | +| `compress.automaticStrategies` | PR #206 | Always-on; no config needed | +| `state.prune.tools` | PR #206 | Internal only; no config | --- diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index 7aa65ab3..d2099a32 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -8,11 +8,11 @@ Active Context Pruning (ACP) 所有可配置参数的完整参考手册。 ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): -| 层级 | 路径 | 作用范围 | -|------|------|---------| -| **全局** | `~/.config/opencode/acp.jsonc` | 所有会话 | -| **配置目录** | `$OPENCODE_CONFIG_DIR/acp.jsonc` | 该配置目录下的所有会话 | -| **项目** | `.opencode/acp.jsonc`(从当前目录向上搜索) | 仅当前项目 | +| 层级 | 路径 | 作用范围 | +| ------------ | ------------------------------------------- | ---------------------- | +| **全局** | `~/.config/opencode/acp.jsonc` | 所有会话 | +| **配置目录** | `$OPENCODE_CONFIG_DIR/acp.jsonc` | 该配置目录下的所有会话 | +| **项目** | `.opencode/acp.jsonc`(从当前目录向上搜索) | 仅当前项目 | > **提示:** 在配置文件中添加 `"$schema": "https://raw.githubusercontent.com/ranxianglei/opencode-acp/master/dcp.schema.json"` 可获得 IDE 自动补全。 @@ -26,8 +26,8 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): "compress": { "maxContextLimit": "70%", "minContextLimit": "70%", - "protectedTools": ["skill"] - } + "protectedTools": ["skill"], + }, } ``` @@ -42,58 +42,66 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): ### 通用参数 #### `enabled` + - **类型:** `boolean` - **默认值:** `true` - **状态:** ACTIVE - **说明:** 主开关。设为 `false` 可完全禁用 ACP。 #### `autoUpdate` + - **类型:** `boolean` - **默认值:** `true` - **状态:** ACTIVE - **说明:** 启动时自动检查并安装 ACP 更新,跟踪安装时所用的 dist-tag/规范(`opencode-acp@stable` 跟随 `stable` 标签;`^1.14.0` 等范围规范跟随 `latest`)。版本锁定的规范永不更新。 #### `debug` + - **类型:** `boolean` - **默认值:** `false` - **状态:** ACTIVE - **说明:** 启用调试模式。设为 `true` 时,ACP 在每次压缩后发送聊天通知,显示块详情,并将 `logLevel` 置为 `debug`(INFO/DEBUG 日志与按请求的上下文快照,`~/.config/opencode/logs/acp/`)。设为 `true` 时此开关优先于 `logLevel`。 #### `logLevel` + - **类型:** `"debug" | "info" | "warn" | "error" | "silent"` - **默认值:** `"info"` - **状态:** ACTIVE - **说明:** 文件日志详细级别(`~/.config/opencode/logs/acp/daily/<日期>.log`)。默认 `info`:默认落盘决策级事件(压缩提示决策、转换摘要、自动更新检查、模型切换等)。`warn`/`error` 减少输出;`silent` 完全关闭文件日志;`debug` 额外启用按请求的上下文快照与详细转储。`debug: true` 时忽略此配置。 #### `storagePath` + - **类型:** `string` - **默认值:** 未设置 — `$XDG_DATA_HOME/opencode/storage/plugin/acp`(即 `~/.local/share/opencode/storage/plugin/acp`) - **状态:** ACTIVE - **说明:** 会话状态文件(`{sessionId}.json`,含压缩块、提示状态、token 统计)的持久化目录。路径语义: - - 绝对路径 → 原样使用 - - `~` / `~/...` → 相对于主目录展开 - - 相对路径 → 相对于项目目录(opencode 启动目录)解析 + - 绝对路径 → 原样使用 + - `~` / `~/...` → 相对于主目录展开 + - 相对路径 → 相对于项目目录(opencode 启动目录)解析 - 目录不存在时会自动创建。若设置了此项、但会话状态文件仍位于默认位置,ACP 会记录一条 WARN(每会话一次)而不会自动迁移——如需保留会话历史,请手动移动该文件。 + 目录不存在时会自动创建。若设置了此项、但会话状态文件仍位于默认位置,ACP 会记录一条 WARN(每会话一次)而不会自动迁移——如需保留会话历史,请手动移动该文件。 #### `pruneNotification` + - **类型:** `"off" | "minimal" | "detailed"` - **默认值:** `"off"` - **状态:** ACTIVE - **说明:** 压缩通知的详细程度。 - - `"off"` — 不显示通知 - - `"minimal"` — 简短的一行摘要 - - `"detailed"` — 完整块详情(主题、token 数量、范围) + - `"off"` — 不显示通知 + - `"minimal"` — 简短的一行摘要 + - `"detailed"` — 完整块详情(主题、token 数量、范围) #### `pruneNotificationType` + - **类型:** `"chat" | "toast"` - **默认值:** `"toast"` - **状态:** ACTIVE - **说明:** 压缩通知的投递方式。 - - `"toast"` — 瞬时弹窗提示(推荐;非阻塞) - - `"chat"` — 注入为聊天消息(部分 provider 拒绝空消息时可能冻结会话) + - `"toast"` — 瞬时弹窗提示(推荐;非阻塞) + - `"chat"` — 注入为聊天消息(部分 provider 拒绝空消息时可能冻结会话) #### `protectedFilePatterns` + - **类型:** `string[]` - **默认值:** `[]` - **状态:** ACTIVE @@ -106,12 +114,14 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): 控制 ACP 斜杠命令(`/acp context`、`/acp stats` 等)。 #### `commands.enabled` + - **类型:** `boolean` - **默认值:** `true` - **状态:** ACTIVE - **说明:** 启用或禁用所有 `/acp` 斜杠命令。 #### `commands.protectedTools` + - **类型:** `string[]` - **默认值:** `["task", "skill", "todowrite", "todoread", "compress", "decompress", "batch", "plan_enter", "plan_exit", "write", "edit"]` - **状态:** ACTIVE @@ -134,6 +144,7 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): 实验性功能,可能变更或移除。 #### `experimental.customPrompts` + - **类型:** `boolean` - **默认值:** `false` - **状态:** EXPERIMENTAL @@ -146,69 +157,80 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): 核心压缩行为。 #### `compress.permission` + - **类型:** `"ask" | "allow" | "deny"` - **默认值:** `"allow"` - **状态:** ACTIVE - **说明:** `compress` 工具的权限级别。 - - `"allow"` — 自动批准压缩调用 - - `"ask"` — 每次压缩前提示用户确认 - - `"deny"` — 阻止所有压缩调用 + - `"allow"` — 自动批准压缩调用 + - `"ask"` — 每次压缩前提示用户确认 + - `"deny"` — 阻止所有压缩调用 #### `compress.showCompression` + - **类型:** `boolean` - **默认值:** `true` - **状态:** ACTIVE - **说明:** 在聊天 UI 中显示压缩状态指示。 #### `compress.summaryBuffer` + - **类型:** `boolean` - **默认值:** `true` - **状态:** ACTIVE - **说明:** 将摘要缓冲区指引注入系统 prompt,帮助模型了解现有块及其覆盖范围。 #### `compress.maxContextLimit` + - **类型:** `number | \`${number}%\`` - **默认值:** `"55%"` - **状态:** ACTIVE - **说明:** 上下文使用率上限(以模型上下文窗口的百分比或绝对 token 数表示)。超过此值时,ACP 提示模型进行压缩。示例:`"55%"` 或 `100000`。 #### `compress.minContextLimit` + - **类型:** `number | \`${number}%\`` - **默认值:** `"80%"` - **状态:** DEPRECATED - **说明:** **已废弃——计划移除。** 轮次/迭代提醒 nudge 的上下文使用率下限。使用率降至此值以下时,ACP 停止注入这些提醒(当限制无法换算为具体数值时同样停止,例如模型上下文窗口未知时的 `"X%"`)。增长 nudge 由 `minNudgeContextPercent` 单独控制。在此之前仍然生效;移除时,轮次/迭代提醒 nudge 的下限门控将随之取消(新配置应依赖增长 nudge 体系)。 #### `compress.modelMaxLimits` + - **类型:** `Record` - **默认值:** `undefined` - **状态:** ACTIVE - **说明:** 按模型覆盖 `maxContextLimit`。以模型 ID 为键。示例:`{"gpt-4": 80000, "claude-3-opus": "50%"}` #### `compress.modelMinLimits` + - **类型:** `Record` - **默认值:** `undefined` - **状态:** DEPRECATED - **说明:** **已废弃——将与 `minContextLimit` 一同移除。** 按模型覆盖 `minContextLimit`。在此之前仍然生效。 #### `compress.contextLimitFallback` + - **类型:** `number` - **默认值:** `128000` - **状态:** ACTIVE - **说明:** 当模型上下文窗口未知时使用的回退窗口(绝对 token 数)——例如未声明 limit 的自定义 provider、从未学到 limit 的 headless spawn+resume 会话,或模型切换使旧 limit 失效后的短暂窗口。驱动所有百分比阈值(`maxContextLimit`/`minContextLimit`)、紧急 nudge 覆盖、批量清理 GC 与 in-flight 工具输出截断。已知真实模型 limit 时始终优先,按模型覆盖(`modelMaxLimits`/`modelMinLimits`)同样优先。设为 `0` 可禁用回退(旧行为:学到 limit 前无安全网)。 #### `compress.nudgeFrequency` + - **类型:** `number` - **默认值:** `5` - **状态:** ACTIVE - **说明:** nudge 注入之间的最小轮数间隔。防止每轮都打扰模型。 #### `compress.minNudgeContextPercent` + - **类型:** `number` - **默认值:** `5` - **状态:** ACTIVE - **说明:** 增长触发型 nudge 的上下文使用率下限(占模型上下文窗口的百分比):增长 nudge 要求上下文使用率达到或超过此百分比(此外还需满足增长阈值)。超过上限(`maxContextLimit`)与 98% 紧急覆盖的 nudge 不受此下限约束。若模型上下文窗口未知,则无法计算该下限,增长 nudge 回退为仅按增长触发的行为。轮次/迭代提醒 nudge 由 `minContextLimit` 控制,而非此字段。默认值刻意设低:在默认 `nudgeGrowthTokens`(50K)下,5% 下限对典型工作周期不生效,仅在非常大的(≥2M 级)窗口上才生效——更高的默认值(如 15%)会在 ≥400K 窗口上生效,并推高大型窗口模型上每个压缩周期的工作区间。设为 `0` 可完全禁用该下限,或调高(如 15–30%)使增长 nudge 等待更大的窗口占用比例。可通过 [`compress.providers`](#compressproviders) 按 provider / 按模型细化(issue #344)。 #### `compress.providers` + - **类型:** `Record`,其中 `ProviderOverrides = Partial & { models?: Record> }`(两级均所有字段可选) - **默认值:** `undefined` - **状态:** ACTIVE @@ -230,43 +252,49 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): "claude-sonnet-4-6": { "minNudgeContextPercent": 30, "maxContextLimit": "70%", - "nudgeGrowthTokens": 20000 - } - } - } - } - } + "nudgeGrowthTokens": 20000, + }, + }, + }, + }, + }, } ``` + 此例中,对 `anthropic/claude-sonnet-4-6`:下限为 30%,超限(over-max)区间从窗口的 70% 开始(而非全局 55%,即更大的工作区间),增长阈值为 20K,nudge 语气为 `strong`(继承自 provider 层)。其他 Anthropic 模型获得 8% 下限与 `strong` 语气,但保留全局的区间与 50K 增长阈值;其余全部使用纯全局值。provider 键为 provider id,model 键为模型 id(取自当前激活会话上报的标识,如 `anthropic`、`claude-sonnet-4-6`)。 #### `compress.nudgeGrowthTokens` + - **类型:** `number` - **默认值:** `50000`(固定值) - **状态:** ACTIVE - **说明:** nudge 增长阈值。当上下文自上次 nudge 以来增长超过此 token 数时,ACP 触发 nudge。默认值为固定值,所有模型上下文窗口大小一致(此前按窗口百分比缩放——v1.14.23 移除,因会导致小窗口模型 nudge 频率约 4 倍偏高)。 #### `compress.toolOutputNudgeThreshold` + - **类型:** `number` - **默认值:** `undefined` - **状态:** ACTIVE - **说明:** 专门针对工具输出的 nudge token 阈值。当工具输出超过此值时,定向 nudge 建议压缩工具输出。 #### `compress.iterationNudgeThreshold` + - **类型:** `number` - **默认值:** `15` - **状态:** ACTIVE - **说明:** 当自上次用户消息以来积累了此数量的消息时,注入迭代 nudge(表示无用户交互的长工具调用链)。 #### `compress.nudgeForce` + - **类型:** `"strong" | "soft"` - **默认值:** `"soft"` - **状态:** ACTIVE - **说明:** nudge 的语气。 - - `"soft"` — 信息性,让模型自行决定 - - `"strong"` — 更紧迫,强调上下文溢出风险 + - `"soft"` — 信息性,让模型自行决定 + - `"strong"` — 更紧迫,强调上下文溢出风险 #### `compress.protectedTools` + - **类型:** `string[]` - **默认值:** `["skill", "compress"]` - **状态:** ACTIVE @@ -275,91 +303,105 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): > **注意:** `"compress"` 无论用户配置如何,都会被强制追加到此列表 — 压缩工具调用绝不能丢失。 #### `compress.protectTags` + - **类型:** `boolean` - **默认值:** `false` - **状态:** ACTIVE - **说明:** 设为 `true` 时,`...` 标签包裹的内容受保护,不被压缩。 #### `compress.protectUserMessages` + - **类型:** `boolean` - **默认值:** `false` - **状态:** ACTIVE - **说明:** 设为 `true` 时,所有用户消息受保护,不被压缩(不仅仅是最后一条)。 #### `compress.maxSummaryLengthHard` + - **类型:** `number` - **默认值:** `20000` - **状态:** ACTIVE - **说明:** 摘要长度的硬限制(字符数)。摘要超过此长度的压缩调用会被拒绝。 #### `compress.minCompressRange` + - **类型:** `number` - **默认值:** `5000` - **状态:** ACTIVE - **说明:** 压缩范围的最小估算 token 数。小于此值的范围会从推荐列表中过滤掉(不值得压缩)。 #### `compress.minNudgeGrowthRatio` + - **类型:** `number` - **默认值:** `0.45` - **状态:** ACTIVE - **说明:** 用于计算 nudge 增长下限的 `nudgeGrowthTokens` 比例。值越大 = nudge 频率越低。 #### `compress.minNudgeGrowthFloor` + - **类型:** `number` - **默认值:** `5000` - **状态:** ACTIVE - **说明:** nudge 增长阈值的最小 token 数。实际阈值为 `max(此值, minNudgeGrowthRatio × nudgeGrowthTokens)`。 #### `compress.emergencyThresholdPercent` + - **类型:** `number | \`${number}%\`` - **默认值:** `"98%"` - **状态:** ACTIVE - **说明:** 触发"紧急"模式的上下文使用率阈值。超过此值时,ACP 覆盖所有保护过滤器,强制 nudge 模型立即压缩。 #### `compress.maxVisibleSegments` + - **类型:** `number` - **默认值:** `50` - **状态:** ACTIVE - **说明:** 系统 prompt 中分段指引显示的最大可见上下文分段数。 #### `compress.keepEmbedMaxChars` + - **类型:** `number` - **默认值:** `2000` - **状态:** ACTIVE - **说明:** 在压缩摘要中使用 `[[KEEP:mNNNNN]]` 标记时,每条消息嵌入的最大字符数。 #### `compress.lastSegmentSoftBlock` + - **类型:** `boolean` - **默认值:** `true` - **状态:** ACTIVE - **说明:** 设为 `true` 时,最后一个可见分段(最新消息)被视为软块 — 从压缩推荐中排除,但可通过 `dangerous: true` 覆盖。 #### `compress.preserveRecentMessages` + - **类型:** `number` - **默认值:** `5` - **状态:** ACTIVE - **说明:** 保护最近 N 条消息不被压缩。这些消息会从可压缩范围中软过滤。设为 `0` 可禁用。 #### `compress.preserveRecentTokens` + - **类型:** `number` - **默认值:** `5000` - **状态:** ACTIVE - **说明:** 近期消息保护的 token 预算。除了最近 N 条消息外,ACP 还保护此 token 预算内的消息(从最近消息向后扩展)。设为 `0` 可禁用。 #### `compress.preserveLastUserMessage` + - **类型:** `boolean` - **默认值:** `true` - **状态:** ACTIVE - **说明:** 始终保护最近一条用户消息不被压缩,无论 `preserveRecentMessages` 或 `preserveRecentTokens` 如何设置。 #### `compress.reasoning` + - **类型:** `object { drop?: boolean; threshold?: number }` - **默认值:** `{ "drop": true, "threshold": 2048 }` - **状态:** ACTIVE (#368) - **说明:** 控制从历史 `compress` 工具调用中丢弃超大 reasoning(思考)部分。`compress` 调用被硬排除在压缩之外,其思考内容会随每次请求原样重发,形成无法回收的上下文底座。本 pass 在请求时(从不修改持久化历史)移除已关闭轮次中 reasoning 总长超过 `threshold` 的 compress 消息的思考部分。活跃轮(最后一条真实用户消息及之后)永不触碰。 - **字段:** - - `drop`(`boolean`,默认 `true`)— 总开关;`false` 完全禁用本 pass。 - - `threshold`(`number`,字符数,默认 `2048`)— 单条思考大小门:消息 reasoning 总长必须**严格大于**该值才丢弃。小思考保留;长度不跨消息累计。`0` 表示丢弃所有非空 reasoning(仅零长 reasoning 幸免)。 + - `drop`(`boolean`,默认 `true`)— 总开关;`false` 完全禁用本 pass。 + - `threshold`(`number`,字符数,默认 `2048`)— 单条思考大小门:消息 reasoning 总长必须**严格大于**该值才丢弃。小思考保留;长度不跨消息累计。`0` 表示丢弃所有非空 reasoning(仅零长 reasoning 幸免)。 - **按 provider/model 覆盖(字段级,走 `compress.providers` cascade): ```jsonc @@ -370,14 +412,17 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): "my-gateway": { "reasoning": { "drop": false } }, "anthropic": { "reasoning": { "threshold": 8000 }, - "models": { "claude-opus-4-5": { "reasoning": { "threshold": 16000 } } } - } - } - } + "models": { "claude-opus-4-5": { "reasoning": { "threshold": 16000 } } }, + }, + }, + }, } ``` - 解析顺序:model 级 `reasoning` > provider 级 `reasoning` > 全局 `compress.reasoning`,逐字段生效(深层只覆盖显式设置的字段)。provider/model id 取自当前请求的模型标识。 + +解析顺序:model 级 `reasoning` > provider 级 `reasoning` > 全局 `compress.reasoning`,逐字段生效(深层只覆盖显式设置的字段)。provider/model id 取自当前请求的模型标识。 + #### `compress.completionReserveTokens` + - **类型:** `number` - **默认值:** `32768` - **状态:** ACTIVE @@ -390,30 +435,35 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): > **注意:** GC 截断模块(`gc/truncate.ts`)已在 v1.14.4 中移除。剩余的 `gc` 字段用于块生成追踪和批量清理(块合并)。旧配置文件中的这些字段仍然有效。 #### `gc.algorithm` + - **类型:** `"truncate"` - **默认值:** `"truncate"` - **状态:** DEPRECATED - **说明:** 历史上用于选择 GC 算法。只实现过 `"truncate"`。现为 no-op — 仅为配置兼容性保留。 #### `gc.promotionThreshold` + - **类型:** `number` - **默认值:** `5` - **状态:** ACTIVE - **说明:** 压缩块在从 `"young"`(新生代)提升为 `"old"`(老生代)之前,必须存活的消息变换周期数。老生代块有资格被 `gc/merge.ts` 批量合并。 #### `gc.maxBlockAge` + - **类型:** `number` - **默认值:** `9007199254740991`(`Number.MAX_SAFE_INTEGER`) - **状态:** DEPRECATED - **说明:** 历史上控制基于块年龄的去激活。已设为无穷大 — 实际禁用。仅为配置兼容性保留。 #### `gc.maxOldGenSummaryLength` + - **类型:** `number` - **默认值:** `3000` - **状态:** ACTIVE - **说明:** 当批量清理将多个老生代块合并为更高层级的块时,合并后摘要的最大长度(字符数)。 #### `gc.majorGcThresholdPercent` + - **类型:** `number | \`${number}%\`` - **默认值:** `"100%"` - **状态:** ACTIVE @@ -424,18 +474,21 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): 批量清理将多个老生代块合并为更高层级的块。 ##### `gc.batchCleanup.lowThreshold` + - **类型:** `number | \`${number}%\`` - **默认值:** `"55%"` - **状态:** ACTIVE - **说明:** 低优先级批量清理的上下文使用率阈值。达到此级别时,批量清理开始考虑合并老生代块。 ##### `gc.batchCleanup.highThreshold` + - **类型:** `number | \`${number}%\`` - **默认值:** `"75%"` - **状态:** ACTIVE - **说明:** 中优先级批量清理阈值。 ##### `gc.batchCleanup.forceThreshold` + - **类型:** `number | \`${number}%\`` - **默认值:** `"90%"` - **状态:** ACTIVE @@ -448,30 +501,33 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): 压缩后质量评估。在每次压缩后运行,验证摘要质量。 #### `qualityGate.enabled` + - **类型:** `boolean` - **默认值:** `false` - **状态:** ACTIVE - **说明:** 启用压缩后质量评估。设为 `true` 时,ACP 根据质量指标评估每个压缩摘要。失败仅记录日志,不阻止压缩(非阻塞)。 #### `qualityGate.algorithm` + - **类型:** `string` - **默认值:** `"rouge-recall-v1"` - **状态:** ACTIVE - **说明:** 使用的质量门控算法。目前仅支持 `"rouge-recall-v1"`。 #### `qualityGate.algorithms` + - **类型:** `object` - **状态:** ACTIVE - **说明:** 算法特定参数。见下文。 ##### `qualityGate.algorithms.rouge-recall-v1` -| 参数 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `layer1MinChars` | number | 200 | 摘要的最小长度(字符) | -| `layer1MinRetentionPct` | number | 5.0 | 最小内容保留百分比 | -| `layer2MaxRougeF1` | number | 0.05 | ROUGE-1 F1 分数"过于相似"检测的最大值 | -| `layer2MaxTop20Recall` | number | 0.20 | 质量检查的最大 top-20 关键词召回率 | +| 参数 | 类型 | 默认值 | 说明 | +| ----------------------- | ------ | ------ | ------------------------------------- | +| `layer1MinChars` | number | 200 | 摘要的最小长度(字符) | +| `layer1MinRetentionPct` | number | 5.0 | 最小内容保留百分比 | +| `layer2MaxRougeF1` | number | 0.05 | ROUGE-1 F1 分数"过于相似"检测的最大值 | +| `layer2MaxTop20Recall` | number | 0.20 | 质量检查的最大 top-20 关键词召回率 | --- @@ -481,21 +537,23 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): 自 v1.14.8 起,ACP 内置了 5 个 oh-my-opencode(OMO)过滤器,全部默认启用: -| 过滤器 | 版本 | 行为 | -|--------|------|------| -| `omo-system-reminder` | 1.3.0 | 保留最近 2 条 OMO `` 消息;更早的会剥离 `` 块和 `` 标记,但**保留你的实际用户内容** | -| `omo-context` | 1.0.0 | 只保留最新的 OMO `[CONTEXT]` 注入;丢弃更早的重复项 | -| `omo-task-directive` | 1.0.0 | 只保留最新的 OMO `TASK:` / `## TASK` 指令;丢弃更早的 | -| `omo-todo-continuation` | 1.0.0 | 只保留最新的 OMO TODO CONTINUATION 指令;丢弃更早的 | -| `omo-mode-injection` | 1.1.0 | 剥离开头的模式块(``、`[search-mode]` 等),保留用户内容 | +| 过滤器 | 版本 | 行为 | +| ----------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `omo-system-reminder` | 1.3.0 | 保留最近 2 条 OMO `` 消息;更早的会剥离 `` 块和 `` 标记,但**保留你的实际用户内容** | +| `omo-context` | 1.0.0 | 只保留最新的 OMO `[CONTEXT]` 注入;丢弃更早的重复项 | +| `omo-task-directive` | 1.0.0 | 只保留最新的 OMO `TASK:` / `## TASK` 指令;丢弃更早的 | +| `omo-todo-continuation` | 1.0.0 | 只保留最新的 OMO TODO CONTINUATION 指令;丢弃更早的 | +| `omo-mode-injection` | 1.1.0 | 剥离开头的模式块(``、`[search-mode]` 等),保留用户内容 | #### `messageFilters.enabled` + - **类型:** `boolean` - **默认值:** `true` - **状态:** ACTIVE - **说明:** 主开关。设为 `false` 时不运行任何过滤器。 #### `messageFilters.filters` + - **类型:** `object` — `Record` - **默认值:** 所有内置过滤器启用 - **状态:** ACTIVE @@ -512,9 +570,9 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): "omo-context": { "enabled": true }, "omo-task-directive": { "enabled": true }, "omo-todo-continuation": { "enabled": true }, - "omo-mode-injection": { "enabled": true } - } - } + "omo-mode-injection": { "enabled": true }, + }, + }, } ``` @@ -523,6 +581,7 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): ## 常用配置模板 ### 激进压缩(最大化上下文节省) + ```jsonc { "compress": { @@ -530,12 +589,13 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): "minContextLimit": "35%", "preserveRecentMessages": 3, "preserveRecentTokens": 2000, - "nudgeFrequency": 3 - } + "nudgeFrequency": 3, + }, } ``` ### 保守压缩(最小化信息丢失) + ```jsonc { "compress": { @@ -544,35 +604,37 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): "preserveRecentMessages": 15, "preserveRecentTokens": 10000, "nudgeFrequency": 8, - "protectedTools": ["skill", "bash", "read", "grep", "glob"] - } + "protectedTools": ["skill", "bash", "read", "grep", "glob"], + }, } ``` ### 完全禁用自动压缩 + ```jsonc -{ -} +{} ``` ### 按模型设置上下文限制 + ```jsonc { "compress": { "modelMaxLimits": { "gpt-4o": 80000, "claude-3.5-sonnet": "60%", - "gemini-1.5-pro": 150000 + "gemini-1.5-pro": 150000, }, "modelMinLimits": { "gpt-4o": 60000, - "claude-3.5-sonnet": "50%" - } - } + "claude-3.5-sonnet": "50%", + }, + }, } ``` ### 按模型设置增长 nudge 下限(嵌套 providers.models) + ```jsonc { "compress": { @@ -582,17 +644,20 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): "minNudgeContextPercent": 8, "models": { "claude-sonnet-4-6": { "minNudgeContextPercent": 30 }, - "claude-haiku-4-5": { "minNudgeContextPercent": 0 } - } - } - } - } + "claude-haiku-4-5": { "minNudgeContextPercent": 0 }, + }, + }, + }, + }, } ``` + 下限按 **模型 > provider > 全局** 逐字段解析。`0` 表示为该模型禁用下限——适用于小窗口模型,仅靠增长阈值触发即可。 ### 按模型调优任意 compress 字段(嵌套 providers.models) + 同样的级联适用于所有可调字段,而不仅是下限——例如给某个重度使用的模型设置更紧的增长阈值与更低的超限区间,而同 provider 的其他模型保持全局配置: + ```jsonc { "compress": { @@ -601,17 +666,19 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): "models": { "claude-sonnet-4-6": { "nudgeGrowthTokens": 20000, - "maxContextLimit": "40%" - } - } - } - } - } + "maxContextLimit": "40%", + }, + }, + }, + }, + }, } ``` + 完整可覆盖字段列表见 [`compress.providers`](#compressproviders) 参考节。 ### 保护敏感文件 + ```jsonc { "protectedFilePatterns": [ @@ -619,13 +686,15 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): "**/*.pem", "**/*.key", "**/credentials.json", - "**/secrets.*" - ] + "**/secrets.*", + ], } ``` ### 调整或禁用 oh-my-opencode(OMO)注入过滤器 + 5 个内置 OMO 过滤器默认开启(见 [`messageFilters`](#messagefilters))。可以保留更多最近的 system-reminder、单独禁用某个过滤器,或整体关闭: + ```jsonc { "messageFilters": { @@ -634,15 +703,16 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): // 保留最近 3 条 OMO system-reminder(默认 2 条) "omo-system-reminder": { "enabled": true, "keepLast": 3 }, // 只禁用一个过滤器,其余保持 - "omo-task-directive": { "enabled": false } - } - } + "omo-task-directive": { "enabled": false }, + }, + }, } ``` + ```jsonc { // 关闭所有消息过滤器 - "messageFilters": { "enabled": false } + "messageFilters": { "enabled": false }, } ``` @@ -651,7 +721,7 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): ```jsonc { // 绝对路径、~ 展开,或相对于项目目录 - "storagePath": "~/data/acp-state" + "storagePath": "~/data/acp-state", } ``` @@ -661,12 +731,12 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): 以下参数存在于旧版本中,现已移除。包含这些参数的配置文件会显示警告提示,但仍可正常工作。 -| 参数 | 移除版本 | 替代方案 | -|------|---------|---------| -| `strategies.deduplication.*` | PR #206 | 压缩工具自动处理重复 | -| `strategies.purgeErrors.*` | PR #206 | 压缩工具自动处理错误清理 | -| `compress.automaticStrategies` | PR #206 | 始终开启;无需配置 | -| `state.prune.tools` | PR #206 | 仅内部使用;无需配置 | +| 参数 | 移除版本 | 替代方案 | +| ------------------------------ | -------- | ------------------------ | +| `strategies.deduplication.*` | PR #206 | 压缩工具自动处理重复 | +| `strategies.purgeErrors.*` | PR #206 | 压缩工具自动处理错误清理 | +| `compress.automaticStrategies` | PR #206 | 始终开启;无需配置 | +| `state.prune.tools` | PR #206 | 仅内部使用;无需配置 | --- diff --git a/dcp.schema.json b/dcp.schema.json index 5bb8b0a4..cafb6579 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -37,20 +37,13 @@ }, "pruneNotification": { "type": "string", - "enum": [ - "off", - "minimal", - "detailed" - ], + "enum": ["off", "minimal", "detailed"], "default": "off", "description": "Level of notification shown when context management occurs" }, "pruneNotificationType": { "type": "string", - "enum": [ - "chat", - "toast" - ], + "enum": ["chat", "toast"], "default": "toast", "description": "Where to display notifications (chat deprecated, falls back to toast)" }, @@ -118,11 +111,7 @@ "properties": { "permission": { "type": "string", - "enum": [ - "ask", - "allow", - "deny" - ], + "enum": ["ask", "allow", "deny"], "default": "allow", "description": "Permission mode (deny disables the tool)" }, @@ -212,10 +201,7 @@ }, "nudgeForce": { "type": "string", - "enum": [ - "strong", - "soft" - ], + "enum": ["strong", "soft"], "default": "soft", "description": "Controls how likely compression is after user messages. 'strong' is more likely, 'soft' is less likely." }, @@ -263,10 +249,7 @@ "description": "Override for the soft upper threshold. Precedence when set here: nested override > modelMaxLimits flat map > global value." }, "emergencyThresholdPercent": { - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "description": "Override for the emergency threshold (absolute tokens or percentage string)." }, "minNudgeContextPercent": { @@ -308,10 +291,7 @@ }, "nudgeForce": { "type": "string", - "enum": [ - "strong", - "soft" - ], + "enum": ["strong", "soft"], "description": "Override for the nudge force mode." }, "protectedTools": { @@ -410,10 +390,7 @@ "description": "Override for the soft upper threshold. Precedence when set here: nested override > modelMaxLimits flat map > global value." }, "emergencyThresholdPercent": { - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "description": "Override for the emergency threshold (absolute tokens or percentage string)." }, "minNudgeContextPercent": { @@ -455,10 +432,7 @@ }, "nudgeForce": { "type": "string", - "enum": [ - "strong", - "soft" - ], + "enum": ["strong", "soft"], "description": "Override for the nudge force mode." }, "protectedTools": { @@ -670,9 +644,7 @@ "properties": { "algorithm": { "type": "string", - "enum": [ - "truncate" - ], + "enum": ["truncate"], "default": "truncate" }, "promotionThreshold": { @@ -792,7 +764,7 @@ }, "layer2MaxTop20Recall": { "type": "number", - "default": 0.20, + "default": 0.2, "minimum": 0, "description": "L2 fails (combined with rougeF1 via AND) when top-20 keyword recall is below this threshold." } @@ -809,7 +781,7 @@ "layer1MinChars": 200, "layer1MinRetentionPct": 1.0, "layer2MaxRougeF1": 0.05, - "layer2MaxTop20Recall": 0.20 + "layer2MaxTop20Recall": 0.2 } } } diff --git a/devlog/2026-08-28_context-budget-guard/WORKLOG.md b/devlog/2026-08-28_context-budget-guard/WORKLOG.md index 936ff2c9..1826f83f 100644 --- a/devlog/2026-08-28_context-budget-guard/WORKLOG.md +++ b/devlog/2026-08-28_context-budget-guard/WORKLOG.md @@ -3,7 +3,7 @@ - Task ID: `2026-08-28_context-budget-guard` - Home Repo: `opencode-acp` - Status: Done -- Updated: 2026-08-28 23:50 +- Updated: 2026-08-29 02:45 ## 1. Summary @@ -37,7 +37,8 @@ | Commit | Description | |--------|-------------| -| `62976d4` | fix: context budget guard + no-window warning (#347) | +| `0fa4e24` | fix: context budget guard + no-window warning (#347) | +| `318c94d` | fix: review fixes — reserve validation, estimate alignment, shrink guard, docs | ### Key Files @@ -52,9 +53,18 @@ `noContextLimitWarned` flag (default/reset false, not persisted). - `dcp.schema.json`, `CONFIGURATION.md`, `CONFIGURATION.zh-CN.md` — docs for `completionReserveTokens`. -- `tests/enforce-budget.test.ts` — new: 14 tests covering window resolution, - estimation, no-op paths, phase 1/2 pruning, protections, idempotency, - over-budget warning. +- `lib/config-validation.ts` — `compress.completionReserveTokens` added to + `VALID_CONFIG_KEYS` + type/value checks in `validateConfigTypes` (review + blocker: the key was documented but not whitelisted → spurious "Unknown + keys" toast). +- `tests/enforce-budget.test.ts` — 23 tests covering window resolution, + estimation (incl. tokenless-aborted-request gap), no-op paths (budget<=0, + <3 messages), non-numeric reserve fallback, phase 1/2 pruning, + non-shrinking truncation skip, protections, idempotency, over-budget + warning. +- `tests/config-validation.test.ts` — +4 tests for + `compress.completionReserveTokens` (key accepted, numeric accepted, wrong + type, negative). ## 3. Design & Implementation Notes @@ -111,8 +121,56 @@ npx tsc --noEmit ### Results - `npm run typecheck` — clean. -- `npm test` — 1043/1043 pass (14 new in `tests/enforce-budget.test.ts`). -- `npm run build` — success (dist/index.js 419.61 KB). +- `npm test` — 1052/1052 pass (23 in `tests/enforce-budget.test.ts`, +4 in + `tests/config-validation.test.ts`). +- `npm run build` — success (dist/index.js 420.69 KB). - Regression check: `tests/e2e-blocks-nudges.test.ts` 10/10 (was failing during development while the guard used the absolute `maxContextLimit` as a window; fixed by restricting the guard to the model-reported window). +- CI pre-flight: `./scripts/ci/check-pr.sh 2026-08-28_context-budget-guard + origin/master` — all checks pass. + +### Dual-Agent Review (2026-08-29) + +Two independent agent reviews (source + tests) of the PR branch. + +**Source review — REQUEST-CHANGES, 1 blocker + 7 minors.** Fixed: +- **BLOCKER**: `compress.completionReserveTokens` missing from + `VALID_CONFIG_KEYS` → spurious "Unknown keys" toast for anyone setting the + documented key. Added to the whitelist + `validateConfigTypes` + (number, >= 0). +- **NaN reserve hazard**: config validation is advisory-only (warns, doesn't + block load), so a non-numeric `completionReserveTokens` made `budget` NaN + and every `<= budget` early-exit false → the guard truncated ALL + candidates. Now falls back to `DEFAULT_COMPLETION_RESERVE_TOKENS`. +- **estimateWireTokens undercount**: anchored additions on the last assistant + *by role* while `getCurrentTokenUsage` anchors on the last assistant *with + token data* (it skips tokenless aborted requests) → the gap between the two + was undercounted. Also, when `getCurrentTokenUsage` itself fell back to + content estimation, the system prompt estimate was dropped. Both fixed. +- **Truncation could grow content**: for output just over the 4000-char + threshold, prefix + suffix overlap and the marker line made the "truncated" + form LONGER. Now skipped (phase 2 may still clear it). +- **Docs contradicted code**: schema + EN/CN docs + the warn-once text claimed + an absolute `compress.maxContextLimit` enables the guard; it deliberately + does not (soft nudge threshold, not the backend's real limit). Corrected. + +Left as known limitations (documented, low impact): +- The one-time no-window warning can fire one turn early for a catalog-unknown + model that declares its limit (messages.transform runs before + system.transform sets the limit). Harmless — once per session. +- First-user-message protection is index-0-only (matches + `truncateLargeToolOutputs`; post-compaction nuance). +- The "still over budget" WARN repeats each transform while the condition + persists (deliberate — a genuinely alarming state). + +**Test review — APPROVE-WITH-NITS.** No blockers. Nits addressed: +- Fragile premise in the clear-phase test (phase-1 shortfall had a ~10% BPE + margin) → rebuilt so phase 1 provably saves 0 (char-count fact) and phase 2 + does all the work; asserts `truncatedCount === 0`. +- Weak fallback-estimate assertion (`est >= 500`) → tightened to near-exact + equality (`2*countTokens(content) + systemPrompt`, ±5 tolerance). +- Coverage gaps added: budget<=0 no-op, <3 messages, non-numeric reserve + fallback, tokenless-aborted-request gap estimate, non-shrinking truncation + skip, +4 config-validation tests. +- Cosmetic: FILLER comment token estimate corrected (~10, not ~12). diff --git a/lib/config-validation.ts b/lib/config-validation.ts index e3707d73..3d56f41f 100644 --- a/lib/config-validation.ts +++ b/lib/config-validation.ts @@ -127,7 +127,11 @@ export function validateConfigTypes(config: Record): ValidationErro } if (config.allowSubAgents !== undefined && typeof config.allowSubAgents !== "boolean") { - errors.push({ key: "allowSubAgents", expected: "boolean", actual: typeof config.allowSubAgents }) + errors.push({ + key: "allowSubAgents", + expected: "boolean", + actual: typeof config.allowSubAgents, + }) } if (config.pruneNotification !== undefined) { @@ -353,10 +357,7 @@ export function validateConfigTypes(config: Record): ValidationErro }) } - if ( - typeof compress.minCompressRange === "number" && - compress.minCompressRange < 0 - ) { + if (typeof compress.minCompressRange === "number" && compress.minCompressRange < 0) { errors.push({ key: "compress.minCompressRange", expected: "non-negative number (>= 0)", @@ -414,7 +415,7 @@ export function validateConfigTypes(config: Record): ValidationErro if (emergencyThreshold < 0) { errors.push({ key: "compress.emergencyThresholdPercent", - expected: "non-negative number or \"${number}%\" (0–100)", + expected: 'non-negative number or "${number}%" (0–100)', actual: `${emergencyThreshold}`, }) } @@ -740,7 +741,11 @@ export function validateConfigTypes(config: Record): ValidationErro reasoning: "reasoningConfig", } - const validateOverrideField = (key: string, type: OverrideFieldType, value: unknown): void => { + const validateOverrideField = ( + key: string, + type: OverrideFieldType, + value: unknown, + ): void => { if (value === undefined) { return } @@ -753,14 +758,11 @@ export function validateConfigTypes(config: Record): ValidationErro case "nonNegativeNumber": case "positiveNumber": { const min = type === "positiveNumber" ? 1 : 0 - if ( - typeof value !== "number" || - !Number.isFinite(value) || - value < min - ) { + if (typeof value !== "number" || !Number.isFinite(value) || value < min) { errors.push({ key, - expected: type === "positiveNumber" ? "number (>= 1)" : "number (>= 0)", + expected: + type === "positiveNumber" ? "number (>= 1)" : "number (>= 0)", actual: JSON.stringify(value), }) } @@ -774,7 +776,11 @@ export function validateConfigTypes(config: Record): ValidationErro break case "nudgeForce": if (value !== "strong" && value !== "soft") { - errors.push({ key, expected: "'strong' | 'soft'", actual: JSON.stringify(value) }) + errors.push({ + key, + expected: "'strong' | 'soft'", + actual: JSON.stringify(value), + }) } break case "stringArray": @@ -782,7 +788,11 @@ export function validateConfigTypes(config: Record): ValidationErro !Array.isArray(value) || !value.every((entry) => typeof entry === "string") ) { - errors.push({ key, expected: "string[]", actual: JSON.stringify(value) }) + errors.push({ + key, + expected: "string[]", + actual: JSON.stringify(value), + }) } break case "reasoningConfig": @@ -824,8 +834,16 @@ export function validateConfigTypes(config: Record): ValidationErro if (overrides === undefined) { return } - if (typeof overrides !== "object" || overrides === null || Array.isArray(overrides)) { - errors.push({ key: prefix, expected: "CompressModelOverrides", actual: typeof overrides }) + if ( + typeof overrides !== "object" || + overrides === null || + Array.isArray(overrides) + ) { + errors.push({ + key: prefix, + expected: "CompressModelOverrides", + actual: typeof overrides, + }) return } const model = overrides as Record @@ -852,7 +870,11 @@ export function validateConfigTypes(config: Record): ValidationErro if (providers === undefined) { return } - if (typeof providers !== "object" || providers === null || Array.isArray(providers)) { + if ( + typeof providers !== "object" || + providers === null || + Array.isArray(providers) + ) { errors.push({ key: "compress.providers", expected: "Record", @@ -862,8 +884,16 @@ export function validateConfigTypes(config: Record): ValidationErro } for (const [providerId, providerValue] of Object.entries(providers)) { const prefix = `compress.providers.${providerId}` - if (typeof providerValue !== "object" || providerValue === null || Array.isArray(providerValue)) { - errors.push({ key: prefix, expected: "ProviderOverrides", actual: typeof providerValue }) + if ( + typeof providerValue !== "object" || + providerValue === null || + Array.isArray(providerValue) + ) { + errors.push({ + key: prefix, + expected: "ProviderOverrides", + actual: typeof providerValue, + }) continue } const provider = providerValue as Record @@ -986,9 +1016,7 @@ export function validateConfigTypes(config: Record): ValidationErro actual: typeof gc.maxOldGenSummaryLength, }) } - if ( - gc.majorGcThresholdPercent !== undefined - ) { + if (gc.majorGcThresholdPercent !== undefined) { const isValidNumber = typeof gc.majorGcThresholdPercent === "number" const isPercentString = typeof gc.majorGcThresholdPercent === "string" && @@ -1003,7 +1031,10 @@ export function validateConfigTypes(config: Record): ValidationErro } const validateBatchThreshold = ( - key: "gc.batchCleanup.lowThreshold" | "gc.batchCleanup.highThreshold" | "gc.batchCleanup.forceThreshold", + key: + | "gc.batchCleanup.lowThreshold" + | "gc.batchCleanup.highThreshold" + | "gc.batchCleanup.forceThreshold", value: unknown, ): void => { const isValidNumber = typeof value === "number" @@ -1030,13 +1061,22 @@ export function validateConfigTypes(config: Record): ValidationErro }) } else { if (gc.batchCleanup.lowThreshold !== undefined) { - validateBatchThreshold("gc.batchCleanup.lowThreshold", gc.batchCleanup.lowThreshold) + validateBatchThreshold( + "gc.batchCleanup.lowThreshold", + gc.batchCleanup.lowThreshold, + ) } if (gc.batchCleanup.highThreshold !== undefined) { - validateBatchThreshold("gc.batchCleanup.highThreshold", gc.batchCleanup.highThreshold) + validateBatchThreshold( + "gc.batchCleanup.highThreshold", + gc.batchCleanup.highThreshold, + ) } if (gc.batchCleanup.forceThreshold !== undefined) { - validateBatchThreshold("gc.batchCleanup.forceThreshold", gc.batchCleanup.forceThreshold) + validateBatchThreshold( + "gc.batchCleanup.forceThreshold", + gc.batchCleanup.forceThreshold, + ) } } } diff --git a/lib/config.ts b/lib/config.ts index 97e3e77c..b7bc0ead 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -3,10 +3,14 @@ import { join, dirname } from "path" import { homedir } from "os" import { parse } from "jsonc-parser/lib/esm/main.js" import type { PluginInput } from "@opencode-ai/plugin" -import { VALID_CONFIG_KEYS, getInvalidConfigKeys, validateConfigTypes, type ValidationError } from "./config-validation" +import { + VALID_CONFIG_KEYS, + getInvalidConfigKeys, + validateConfigTypes, + type ValidationError, +} from "./config-validation" import type { LogLevel } from "./logger" - type Permission = "ask" | "allow" | "deny" /** @@ -24,7 +28,12 @@ type Permission = "ask" | "allow" | "deny" */ export type CompressOverridableConfig = Omit< CompressConfig, - "permission" | "minContextLimit" | "modelMaxLimits" | "modelMinLimits" | "providers" | "reasoning" + | "permission" + | "minContextLimit" + | "modelMaxLimits" + | "modelMinLimits" + | "providers" + | "reasoning" > /** Per-model / per-provider override object (all overridable fields optional). */ @@ -104,6 +113,7 @@ export interface CompressConfig { * `getConfig()` always populates it via `DEFAULT_COMPRESS_REASONING`. */ reasoning?: CompressReasoningConfig + /** * Tokens reserved for the model's completion when enforcing the context * budget guard (default: 32768 — covers opencode's 32000 max_tokens * fallback for models with no declared limit.output). @@ -138,7 +148,7 @@ export interface CompressReasoningConfig { export const DEFAULT_COMPRESS_REASONING: Readonly = { drop: true, threshold: 2048, -}} +} export interface Commands { enabled: boolean @@ -233,7 +243,12 @@ const COMPRESS_DEFAULT_PROTECTED_TOOLS = ["skill", "compress"] */ const FORCE_COMPRESS_PROTECTED: readonly string[] = ["compress"] -export { VALID_CONFIG_KEYS, getInvalidConfigKeys, validateConfigTypes, type ValidationError } from "./config-validation" +export { + VALID_CONFIG_KEYS, + getInvalidConfigKeys, + validateConfigTypes, + type ValidationError, +} from "./config-validation" function showConfigWarnings( ctx: PluginInput, @@ -347,7 +362,7 @@ const defaultConfig: PluginConfig = { layer1MinChars: 200, layer1MinRetentionPct: 5.0, layer2MaxRougeF1: 0.05, - layer2MaxTop20Recall: 0.20, + layer2MaxTop20Recall: 0.2, }, }, }, @@ -481,7 +496,7 @@ function stripUndefined(value: T): T { */ function mergeModelOverrides( base: Record | undefined, - override: Record | undefined + override: Record | undefined, ): Record | undefined { if (base === undefined) return override if (override === undefined) return base @@ -501,7 +516,7 @@ function mergeModelOverrides( */ function mergeProviderOverrides( base: Record | undefined, - override: Record | undefined + override: Record | undefined, ): Record | undefined { if (base === undefined) return override if (override === undefined) return base @@ -548,24 +563,26 @@ export function mergeCompress( protectTags: override.protectTags ?? base.protectTags, protectUserMessages: override.protectUserMessages ?? base.protectUserMessages, maxSummaryLengthHard: override.maxSummaryLengthHard ?? base.maxSummaryLengthHard, - minCompressRange: override.minCompressRange ?? base.minCompressRange, - minNudgeGrowthRatio: override.minNudgeGrowthRatio ?? base.minNudgeGrowthRatio, - minNudgeGrowthFloor: override.minNudgeGrowthFloor ?? base.minNudgeGrowthFloor, - emergencyThresholdPercent: override.emergencyThresholdPercent ?? base.emergencyThresholdPercent, - maxVisibleSegments: override.maxVisibleSegments ?? base.maxVisibleSegments, - keepEmbedMaxChars: override.keepEmbedMaxChars ?? base.keepEmbedMaxChars, - lastSegmentSoftBlock: override.lastSegmentSoftBlock ?? base.lastSegmentSoftBlock, - preserveRecentMessages: override.preserveRecentMessages ?? base.preserveRecentMessages, - preserveRecentTokens: override.preserveRecentTokens ?? base.preserveRecentTokens, - preserveLastUserMessage: override.preserveLastUserMessage ?? base.preserveLastUserMessage, - reasoning: { - drop: override.reasoning?.drop ?? base.reasoning?.drop ?? DEFAULT_COMPRESS_REASONING.drop, - threshold: - override.reasoning?.threshold ?? - base.reasoning?.threshold ?? - DEFAULT_COMPRESS_REASONING.threshold, - }, - completionReserveTokens: override.completionReserveTokens ?? base.completionReserveTokens, + minCompressRange: override.minCompressRange ?? base.minCompressRange, + minNudgeGrowthRatio: override.minNudgeGrowthRatio ?? base.minNudgeGrowthRatio, + minNudgeGrowthFloor: override.minNudgeGrowthFloor ?? base.minNudgeGrowthFloor, + emergencyThresholdPercent: + override.emergencyThresholdPercent ?? base.emergencyThresholdPercent, + maxVisibleSegments: override.maxVisibleSegments ?? base.maxVisibleSegments, + keepEmbedMaxChars: override.keepEmbedMaxChars ?? base.keepEmbedMaxChars, + lastSegmentSoftBlock: override.lastSegmentSoftBlock ?? base.lastSegmentSoftBlock, + preserveRecentMessages: override.preserveRecentMessages ?? base.preserveRecentMessages, + preserveRecentTokens: override.preserveRecentTokens ?? base.preserveRecentTokens, + preserveLastUserMessage: override.preserveLastUserMessage ?? base.preserveLastUserMessage, + reasoning: { + drop: + override.reasoning?.drop ?? base.reasoning?.drop ?? DEFAULT_COMPRESS_REASONING.drop, + threshold: + override.reasoning?.threshold ?? + base.reasoning?.threshold ?? + DEFAULT_COMPRESS_REASONING.threshold, + }, + completionReserveTokens: override.completionReserveTokens ?? base.completionReserveTokens, } } @@ -616,15 +633,14 @@ export function deepCloneConfig(config: PluginConfig): PluginConfig { ...(provider.models ? { models: Object.fromEntries( - Object.entries(provider.models).map(([modelId, model]) => [ - modelId, - { ...model }, - ]) + Object.entries(provider.models).map( + ([modelId, model]) => [modelId, { ...model }], + ), ), } : {}), }, - ]) + ]), ) : undefined, protectedTools: [...config.compress.protectedTools], @@ -698,7 +714,8 @@ function mergeLayer(config: PluginConfig, data: Record): PluginConf debug: data.debug ?? config.debug, logLevel: data.logLevel ?? config.logLevel, storagePath: data.storagePath ?? config.storagePath, - allowSubAgents: data.allowSubAgents ?? data.experimental?.allowSubAgents ?? config.allowSubAgents, + allowSubAgents: + data.allowSubAgents ?? data.experimental?.allowSubAgents ?? config.allowSubAgents, pruneNotification: data.pruneNotification ?? config.pruneNotification, pruneNotificationType: data.pruneNotificationType ?? config.pruneNotificationType, commands: mergeCommands(config.commands, data.commands as any), @@ -708,8 +725,14 @@ function mergeLayer(config: PluginConfig, data: Record): PluginConf ], compress: mergeCompress(config.compress, data.compress as CompressOverride), gc: mergeGC(config.gc, data.gc as Partial), - qualityGate: mergeQualityGate(config.qualityGate, data.qualityGate as Partial), - messageFilters: mergeMessageFilters(config.messageFilters, data.messageFilters as Partial), + qualityGate: mergeQualityGate( + config.qualityGate, + data.qualityGate as Partial, + ), + messageFilters: mergeMessageFilters( + config.messageFilters, + data.messageFilters as Partial, + ), } } diff --git a/lib/hooks.ts b/lib/hooks.ts index e5439915..170feb35 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -30,10 +30,7 @@ import { getLastUserMessage } from "./messages/query" import { OUTPUT_RESERVE_TOKENS, truncateLargeToolOutputs } from "./messages/truncate-tools" import { resolveEffectiveContextLimit } from "./state/utils" import { enforceContextBudget } from "./messages/enforce-budget" -import { - handleContextCommand, - handleStatsCommand, -} from "./commands" +import { handleContextCommand, handleStatsCommand } from "./commands" import { handleExportCommand } from "./commands/export" import { sendIgnoredMessage } from "./ui/notification" import { type HostPermissionSnapshot } from "./host-permissions" @@ -42,7 +39,13 @@ import { hideConsumedCompressCalls } from "./compress/hide-consumed" import { hideFailedCompressCalls } from "./compress/hide-failed" import { applyMessageFilters } from "./messages/filter/apply" import { ensureBuiltinFiltersRegistered } from "./messages/filter/builtin" -import { createSessionState, saveSessionState, syncToolCache, updatePerTurnState, type SessionStateRegistry } from "./state" +import { + createSessionState, + saveSessionState, + syncToolCache, + updatePerTurnState, + type SessionStateRegistry, +} from "./state" import { cacheSystemPromptTokens } from "./ui/utils" import { runBatchCleanup } from "./gc/merge" import { getCurrentTokenUsage } from "./token-utils" @@ -277,7 +280,8 @@ export function createChatMessageTransformHandler( !state.noContextLimitWarned && requestModel?.providerID && requestModel?.modelID && - registry.resolveModelLimit(requestModel.providerID, requestModel.modelID) === undefined + registry.resolveModelLimit(requestModel.providerID, requestModel.modelID) === + undefined ) { state.noContextLimitWarned = true logger.warn( @@ -307,7 +311,8 @@ export function createChatMessageTransformHandler( // fallback). Runs BEFORE token accounting / pruning so every later // stage sees the post-drop array. const dropReasoningModel = ( - lastUserMessage?.info as { model?: { providerID?: string; modelID?: string } } | undefined + lastUserMessage?.info as + { model?: { providerID?: string; modelID?: string } } | undefined )?.model const reasoningConfig = applyCompressOverrides( config, @@ -338,7 +343,8 @@ export function createChatMessageTransformHandler( assignMessageRefs(state, output.messages) const activeBlockCountBefore = state.prune.messages.activeBlockIds.size // [FIX Bug 4] syncCompressionBlocks(state, logger, output.messages) - if (state.prune.messages.activeBlockIds.size !== activeBlockCountBefore) { // [FIX Bug 4] + if (state.prune.messages.activeBlockIds.size !== activeBlockCountBefore) { + // [FIX Bug 4] saveSessionState(state, logger).catch(() => {}) // [FIX Bug 4] persist deactivations } syncToolCache(state, config, logger, output.messages) @@ -469,12 +475,7 @@ export function createCommandExecuteHandler( }) const messages = filterMessages(messagesResponse.data || messagesResponse) - const state = await registry.getOrCreate( - client, - input.sessionID, - messages, - config, - ) + const state = await registry.getOrCreate(client, input.sessionID, messages, config) syncCompressPermissionState(state, config, hostPermissions, messages) @@ -501,13 +502,7 @@ export function createCommandExecuteHandler( } if (sub === "help") { - await sendIgnoredMessage( - client, - input.sessionID, - buildHelpText(), - {}, - logger, - ) + await sendIgnoredMessage(client, input.sessionID, buildHelpText(), {}, logger) throw new Error("__DCP_CONTEXT_HANDLED__") } @@ -609,9 +604,7 @@ export function createEventHandler(registry: SessionStateRegistry, logger: Logge } if (typeof part.callID === "string" && typeof part.messageID === "string") { - timing.startsByCallId.delete( - buildCompressionTimingKey(part.messageID, part.callID), - ) + timing.startsByCallId.delete(buildCompressionTimingKey(part.messageID, part.callID)) } } }