diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 8a6c6d5e..83c0894a 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -157,15 +157,15 @@ Core compression behavior. #### `compress.maxContextLimit` - **Type:** `number | \`${number}%\`` -- **Default:** `"55%"` +- **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: `"55%"` or `100000`. +- **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:** `"45%"` +- **Default:** `"80%"` - **Status:** ACTIVE -- **Description:** Lower context usage threshold. ACP stops nudging when usage drops below this level. +- **Description:** 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`. #### `compress.modelMaxLimits` - **Type:** `Record` @@ -187,9 +187,41 @@ Core compression behavior. #### `compress.minNudgeContextPercent` - **Type:** `number` -- **Default:** `15` +- **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 -- **Description:** Minimum context usage percentage before any nudges are shown. Below this, no nudges are injected. +- **Description:** Nested per-provider / per-model overrides for **every tunable compress field**, resolved field-by-field with the cascade **model > provider > global** (mirrors the sibling project billion-context-pi, issue #344). Deeper levels only override when the field is explicitly set — unset fields never clear shallower values. `0` / `false` are explicit values, not "unset". Unknown provider/model ids fall back to the global value. Percentages and `"X%"` limits resolve against the active model's context window. Across the three config file layers (global → config dir → project) the maps deep-merge per provider/model key — a project layer can narrow one provider without wiping others configured in lower layers. +- **Overridable fields:** `maxContextLimit`, `emergencyThresholdPercent`, `minNudgeContextPercent`, `nudgeFrequency`, `iterationNudgeThreshold`, `toolOutputNudgeThreshold`, `nudgeGrowthTokens`, `minNudgeGrowthRatio`, `minNudgeGrowthFloor`, `nudgeForce`, `protectedTools`, `showCompression`, `summaryBuffer`, `protectTags`, `protectUserMessages`, `maxSummaryLengthHard`, `minCompressRange`, `maxVisibleSegments`, `keepEmbedMaxChars`, `lastSegmentSoftBlock`, `preserveRecentMessages`, `preserveRecentTokens`, `preserveLastUserMessage`. +- **Not overridable:** `permission` (session-level, fixed before model info is known), the deprecated `minContextLimit` / `modelMinLimits` family, the flat `modelMaxLimits` map (legacy), and `providers` itself. For `maxContextLimit` the precedence when set nested is **nested override > `modelMaxLimits` flat map > global**. `protectedTools` set here affects the compress tool and nudge-side logic; the system-prompt protected-tools listing (shown at prompt build time, before model info is available) always reflects the global value. + +```jsonc +{ + "compress": { + "maxContextLimit": "55%", + "minNudgeContextPercent": 5, + "nudgeGrowthTokens": 50000, + "providers": { + "anthropic": { + "minNudgeContextPercent": 8, + "nudgeForce": "strong", + "models": { + "claude-sonnet-4-6": { + "minNudgeContextPercent": 30, + "maxContextLimit": "70%", + "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` @@ -447,6 +479,45 @@ Post-compression quality evaluation. Runs after each compression to verify summa } ``` +### Per-model growth-nudge floor (nested providers.models) +```jsonc +{ + "compress": { + "minNudgeContextPercent": 5, + "providers": { + "anthropic": { + "minNudgeContextPercent": 8, + "models": { + "claude-sonnet-4-6": { "minNudgeContextPercent": 30 }, + "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": { + "providers": { + "anthropic": { + "models": { + "claude-sonnet-4-6": { + "nudgeGrowthTokens": 20000, + "maxContextLimit": "40%" + } + } + } + } + } +} +``` +See the [`compress.providers`](#compressproviders) reference for the full overridable field list. + ### Protect sensitive files ```jsonc { diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index 3fa5ad9e..203da56c 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -187,9 +187,41 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): #### `compress.minNudgeContextPercent` - **类型:** `number` -- **默认值:** `15` +- **默认值:** `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 -- **说明:** 触发任何 nudge 的最低上下文使用率百分比。低于此值时不注入 nudge。 +- **说明:** 对**所有可调 compress 字段**的嵌套按 provider / 按模型覆盖,逐字段按 **模型 > provider > 全局** 级联解析(与姊妹项目 billion-context-pi 一致,issue #344)。深层仅在该字段被显式设置时才覆盖——未设置的字段不会清空浅层取值;`0` / `false` 是显式值,而非“未设置”。未知的 provider/model id 回退到全局值。百分比与 `"X%"` 限额按当前激活模型的上下文窗口换算。在三个配置文件层(全局 → 配置目录 → 项目)之间,该映射按 provider/model 键深度合并——项目层可以只细化某个 provider 而不清掉低层配置的其他 provider。 +- **可覆盖字段:** `maxContextLimit`、`emergencyThresholdPercent`、`minNudgeContextPercent`、`nudgeFrequency`、`iterationNudgeThreshold`、`toolOutputNudgeThreshold`、`nudgeGrowthTokens`、`minNudgeGrowthRatio`、`minNudgeGrowthFloor`、`nudgeForce`、`protectedTools`、`showCompression`、`summaryBuffer`、`protectTags`、`protectUserMessages`、`maxSummaryLengthHard`、`minCompressRange`、`maxVisibleSegments`、`keepEmbedMaxChars`、`lastSegmentSoftBlock`、`preserveRecentMessages`、`preserveRecentTokens`、`preserveLastUserMessage`。 +- **不可覆盖:** `permission`(会话级,在得知模型信息前已固定)、已废弃的 `minContextLimit` / `modelMinLimits` 系列、旧版扁平 `modelMaxLimits` 映射、以及 `providers` 本身。`maxContextLimit` 在嵌套层设置时的优先级为 **嵌套覆盖 > `modelMaxLimits` 扁平映射 > 全局**。在此设置的 `protectedTools` 影响压缩工具与 nudge 侧逻辑;系统提示词中的受保护工具列表(在提示词构建时生成,早于模型信息可用)始终反映全局值。 + +```jsonc +{ + "compress": { + "maxContextLimit": "55%", + "minNudgeContextPercent": 5, + "nudgeGrowthTokens": 50000, + "providers": { + "anthropic": { + "minNudgeContextPercent": 8, + "nudgeForce": "strong", + "models": { + "claude-sonnet-4-6": { + "minNudgeContextPercent": 30, + "maxContextLimit": "70%", + "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` @@ -447,6 +479,45 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): } ``` +### 按模型设置增长 nudge 下限(嵌套 providers.models) +```jsonc +{ + "compress": { + "minNudgeContextPercent": 5, + "providers": { + "anthropic": { + "minNudgeContextPercent": 8, + "models": { + "claude-sonnet-4-6": { "minNudgeContextPercent": 30 }, + "claude-haiku-4-5": { "minNudgeContextPercent": 0 } + } + } + } + } +} +``` +下限按 **模型 > provider > 全局** 逐字段解析。`0` 表示为该模型禁用下限——适用于小窗口模型,仅靠增长阈值触发即可。 + +### 按模型调优任意 compress 字段(嵌套 providers.models) +同样的级联适用于所有可调字段,而不仅是下限——例如给某个重度使用的模型设置更紧的增长阈值与更低的超限区间,而同 provider 的其他模型保持全局配置: +```jsonc +{ + "compress": { + "providers": { + "anthropic": { + "models": { + "claude-sonnet-4-6": { + "nudgeGrowthTokens": 20000, + "maxContextLimit": "40%" + } + } + } + } + } +} +``` +完整可覆盖字段列表见 [`compress.providers`](#compressproviders) 参考节。 + ### 保护敏感文件 ```jsonc { diff --git a/README.md b/README.md index 6c98d06b..58557ff2 100644 --- a/README.md +++ b/README.md @@ -324,11 +324,12 @@ Each level overrides the previous, so project settings take priority over global // Soft upper threshold: above this, ACP keeps injecting strong // compression nudges (based on nudgeFrequency), so compression is // much more likely. Accepts: number or "X%" of model context window. - "maxContextLimit": "55%", - // Soft lower threshold for reminder nudges: below this, turn/iteration - // reminders are off (compression less likely). At/above this, reminders - // are on. Accepts: number or "X%" of model context window. - "minContextLimit": "45%", + "maxContextLimit": "80%", + // Soft lower threshold for turn/iteration reminder nudges: below this, + // those reminders are off (compression less likely). At/above this, they + // are on. Growth nudges have their own floor: minNudgeContextPercent. + // Accepts: number or "X%" of model context window. + "minContextLimit": "80%", // Optional per-model override for maxContextLimit by providerID/modelID. // If present, this wins over the global maxContextLimit. // Accepts: number or "X%". @@ -343,6 +344,21 @@ Each level overrides the previous, so project settings take priority over global // "openai/gpt-5.3-codex": 50000, // "anthropic/claude-sonnet-4.6": "25%" // }, + // Nested per-provider/per-model overrides for ANY compress field + // (23 fields: thresholds, nudge behavior, protection, ...). + // Resolution is per field: model > provider > global. Unknown + // provider/model IDs fall back to the global value. + // "providers": { + // "anthropic": { + // "nudgeGrowthTokens": 50000, + // "models": { + // "claude-sonnet-4.6": { + // "maxContextLimit": "70%", + // "minNudgeContextPercent": 10 + // } + // } + // } + // }, // How often the context-limit nudge fires (1 = every fetch, 5 = every 5th) "nudgeFrequency": 5, // Start adding compression reminders after this many @@ -402,6 +418,33 @@ Each level overrides the previous, so project settings take priority over global +### Per-Provider / Per-Model Overrides + +Any `compress` field can be overridden per provider and per model via the nested `compress.providers` map: + +```jsonc +{ + "compress": { + "maxContextLimit": "80%", + "providers": { + "anthropic": { + "nudgeGrowthTokens": 20000, + "models": { + "claude-sonnet-4.6": { "maxContextLimit": "70%", "nudgeForce": "strong" } + } + }, + "openai": { "nudgeGrowthTokens": 40000 } + } + } +} +``` + +Resolution is **per field**: model > provider > global. Unknown provider/model IDs fall back to the global value. A nested `maxContextLimit` also wins over the legacy flat `modelMaxLimits` map. Overrides deep-merge across the three config layers (global → config dir → project) per provider/model key. + +Not overridable here: `permission`, the deprecated `minContextLimit` family, and the flat `model*Limits` maps themselves. + +See the [`compress.providers`](./CONFIGURATION.md#compressproviders) reference in CONFIGURATION.md for the full 23-field list and recipes. + ### Prompt Overrides ACP exposes six editable prompts: diff --git a/README.zh-CN.md b/README.zh-CN.md index 20d853f5..5ed555aa 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -297,6 +297,21 @@ ACP 使用自己的配置文件,按以下顺序搜索: // "openai/gpt-5.3-codex": 50000, // "anthropic/claude-sonnet-4.6": "25%" // }, + // 嵌套的 provider/model 级覆盖:可覆盖任意 compress 字段 + // (23 项:阈值、nudge 行为、保护策略等)。 + // 逐字段解析优先级:model > provider > 全局。 + // 未知的 provider/model ID 回退到全局值。 + // "providers": { + // "anthropic": { + // "nudgeGrowthTokens": 50000, + // "models": { + // "claude-sonnet-4.6": { + // "maxContextLimit": "70%", + // "minNudgeContextPercent": 10 + // } + // } + // } + // }, // How often the context-limit nudge fires (1 = every fetch, 5 = every 5th) "nudgeFrequency": 5, // Start adding compression reminders after this many @@ -355,6 +370,33 @@ ACP 使用自己的配置文件,按以下顺序搜索: +### 按 Provider / 按模型覆盖 + +任意 `compress` 字段都可通过嵌套的 `compress.providers` 按 provider 和按模型覆盖: + +```jsonc +{ + "compress": { + "maxContextLimit": "80%", + "providers": { + "anthropic": { + "nudgeGrowthTokens": 20000, + "models": { + "claude-sonnet-4.6": { "maxContextLimit": "70%", "nudgeForce": "strong" } + } + }, + "openai": { "nudgeGrowthTokens": 40000 } + } + } +} +``` + +解析为**逐字段**优先级:model > provider > 全局。未知的 provider/model ID 回退到全局值。嵌套的 `maxContextLimit` 同时优先于旧版扁平 `modelMaxLimits` 映射。多层配置(全局 → 配置目录 → 项目)按 provider/model 键深合并。 + +不可覆盖:`permission`、已废弃的 `minContextLimit` 系列,以及扁平 `model*Limits` 映射自身。 + +完整 23 项字段清单与配方见 CONFIGURATION.zh-CN.md 的 [`compress.providers`](./CONFIGURATION.zh-CN.md#compressproviders) 参考节。 + ### Prompt 覆盖 ACP 暴露六个可编辑的 prompt: diff --git a/dcp.schema.json b/dcp.schema.json index 5b6fd1db..324b41de 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -229,9 +229,279 @@ }, "minNudgeContextPercent": { "type": "number", - "default": 15, + "default": 5, "minimum": 0, - "description": "Minimum context usage percent to show per-message nudges" + "description": "Minimum context usage percent for growth-triggered nudges; growth nudges below this floor are suppressed (0 disables the floor)" + }, + "providers": { + "type": "object", + "description": "Nested per-provider / per-model overrides for every tunable compress field (issue #344). Field-wise cascade: providers[p].models[m] > providers[p] > the global compress value. Unset fields never clear shallower values; 0 is an explicit value (e.g. disable a floor). Unknown provider/model ids fall back to the global value. Not overridable here: permission (session-level), minContextLimit/modelMinLimits (deprecated), modelMaxLimits (flat map), and this providers map itself. maxContextLimit set here takes precedence over modelMaxLimits.", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "maxContextLimit": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\d+(?:\\.\\d+)?%$" + } + ], + "description": "Override for the soft upper threshold. Precedence when set here: nested override > modelMaxLimits flat map > global value." + }, + "emergencyThresholdPercent": { + "type": [ + "number", + "string" + ], + "description": "Override for the emergency threshold (absolute tokens or percentage string)." + }, + "minNudgeContextPercent": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Growth-nudge floor percent, resolved against the active model's context window (0 disables the floor)." + }, + "nudgeFrequency": { + "type": "number", + "minimum": 1, + "description": "Override for the context-limit nudge frequency." + }, + "iterationNudgeThreshold": { + "type": "number", + "minimum": 1, + "description": "Override for the iteration nudge threshold." + }, + "toolOutputNudgeThreshold": { + "type": "number", + "minimum": 0, + "description": "Override for the tool-output nudge threshold (absolute tokens)." + }, + "nudgeGrowthTokens": { + "type": "number", + "minimum": 0, + "description": "Override for the per-message nudge growth threshold (absolute tokens)." + }, + "minNudgeGrowthRatio": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Override for the growth ratio (× nudgeGrowthTokens)." + }, + "minNudgeGrowthFloor": { + "type": "number", + "minimum": 0, + "description": "Override for the absolute growth floor (tokens)." + }, + "nudgeForce": { + "type": "string", + "enum": [ + "strong", + "soft" + ], + "description": "Override for the nudge force mode." + }, + "protectedTools": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Override for protected tool patterns (replaces the inherited list)." + }, + "showCompression": { + "type": "boolean", + "description": "Override for showing compression summaries in notifications." + }, + "summaryBuffer": { + "type": "boolean", + "description": "Override for counting active summary tokens toward the effective maxContextLimit." + }, + "protectTags": { + "type": "boolean", + "description": "Override for tag preservation." + }, + "protectUserMessages": { + "type": "boolean", + "description": "Override for user-message protection." + }, + "maxSummaryLengthHard": { + "type": "number", + "minimum": 1, + "description": "Override for the hard summary length limit (chars)." + }, + "minCompressRange": { + "type": "number", + "minimum": 0, + "description": "Override for the minimum compressible range (chars)." + }, + "maxVisibleSegments": { + "type": "number", + "minimum": 1, + "description": "Override for the max disjoint visible segments shown." + }, + "keepEmbedMaxChars": { + "type": "number", + "minimum": 0, + "description": "Override for the max embedded-content chars kept in summaries." + }, + "lastSegmentSoftBlock": { + "type": "boolean", + "description": "Override for the recent-message protection master switch." + }, + "preserveRecentMessages": { + "type": "number", + "minimum": 0, + "description": "Override for the protected recent-message count." + }, + "preserveRecentTokens": { + "type": "number", + "minimum": 0, + "description": "Override for the protected recent-token window." + }, + "preserveLastUserMessage": { + "type": "boolean", + "description": "Override for protecting the most recent user message." + }, + "models": { + "type": "object", + "description": "Per-model overrides by model id (highest precedence)", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "maxContextLimit": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\d+(?:\\.\\d+)?%$" + } + ], + "description": "Override for the soft upper threshold. Precedence when set here: nested override > modelMaxLimits flat map > global value." + }, + "emergencyThresholdPercent": { + "type": [ + "number", + "string" + ], + "description": "Override for the emergency threshold (absolute tokens or percentage string)." + }, + "minNudgeContextPercent": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Growth-nudge floor percent, resolved against the active model's context window (0 disables the floor)." + }, + "nudgeFrequency": { + "type": "number", + "minimum": 1, + "description": "Override for the context-limit nudge frequency." + }, + "iterationNudgeThreshold": { + "type": "number", + "minimum": 1, + "description": "Override for the iteration nudge threshold." + }, + "toolOutputNudgeThreshold": { + "type": "number", + "minimum": 0, + "description": "Override for the tool-output nudge threshold (absolute tokens)." + }, + "nudgeGrowthTokens": { + "type": "number", + "minimum": 0, + "description": "Override for the per-message nudge growth threshold (absolute tokens)." + }, + "minNudgeGrowthRatio": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "Override for the growth ratio (× nudgeGrowthTokens)." + }, + "minNudgeGrowthFloor": { + "type": "number", + "minimum": 0, + "description": "Override for the absolute growth floor (tokens)." + }, + "nudgeForce": { + "type": "string", + "enum": [ + "strong", + "soft" + ], + "description": "Override for the nudge force mode." + }, + "protectedTools": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Override for protected tool patterns (replaces the inherited list)." + }, + "showCompression": { + "type": "boolean", + "description": "Override for showing compression summaries in notifications." + }, + "summaryBuffer": { + "type": "boolean", + "description": "Override for counting active summary tokens toward the effective maxContextLimit." + }, + "protectTags": { + "type": "boolean", + "description": "Override for tag preservation." + }, + "protectUserMessages": { + "type": "boolean", + "description": "Override for user-message protection." + }, + "maxSummaryLengthHard": { + "type": "number", + "minimum": 1, + "description": "Override for the hard summary length limit (chars)." + }, + "minCompressRange": { + "type": "number", + "minimum": 0, + "description": "Override for the minimum compressible range (chars)." + }, + "maxVisibleSegments": { + "type": "number", + "minimum": 1, + "description": "Override for the max disjoint visible segments shown." + }, + "keepEmbedMaxChars": { + "type": "number", + "minimum": 0, + "description": "Override for the max embedded-content chars kept in summaries." + }, + "lastSegmentSoftBlock": { + "type": "boolean", + "description": "Override for the recent-message protection master switch." + }, + "preserveRecentMessages": { + "type": "number", + "minimum": 0, + "description": "Override for the protected recent-message count." + }, + "preserveRecentTokens": { + "type": "number", + "minimum": 0, + "description": "Override for the protected recent-token window." + }, + "preserveLastUserMessage": { + "type": "boolean", + "description": "Override for protecting the most recent user message." + } + } + } + } + } + } }, "maxSummaryLengthHard": { "type": "number", @@ -312,7 +582,7 @@ "protectedTools": ["skill", "compress"], "protectTags": false, "protectUserMessages": false, - "minNudgeContextPercent": 15, + "minNudgeContextPercent": 5, "maxSummaryLengthHard": 20000, "maxVisibleSegments": 50, "minCompressRange": 5000, diff --git a/devlog/2026-08-28_min-gate-growth-nudges/REQ.md b/devlog/2026-08-28_min-gate-growth-nudges/REQ.md new file mode 100644 index 00000000..d83819df --- /dev/null +++ b/devlog/2026-08-28_min-gate-growth-nudges/REQ.md @@ -0,0 +1,75 @@ +# REQ - Gate T1 growth nudges on the minNudgeContextPercent floor + +- Task ID: `2026-08-28_min-gate-growth-nudges` +- Home Repo: `opencode-acp` +- Created: 2026-08-28 +- Status: Done +- Priority: P1 +- Owner: ework-daemon (qwen3.8-27b) +- References: issue ranxianglei/opencode-acp#342 + +## 1. Background & Problem Statement + +- **Context**: ACP's T1 (message/range) compression nudges should not fire when the working context is too small to be worth compressing. The intended knob for that is `minNudgeContextPercent` (default **15**, a percent of the model context) — the "don't nudge below this" floor. It was plumbed into `computeShouldNudge` but **ignored** by the external trigger policy, so it was a no-op and growth nudges fired at any context size. (`minContextLimit` / `maxContextLimit` are a separate concern — the soft limit-reminder thresholds, both defaulting to **80%**.) +- **Current behavior (symptom)**: T1 *growth* nudges fire well below any configured floor. In the reporter's setup (400K window, `modelMinLimits=150000`, `modelMaxLimits=215000`, `nudgeGrowthTokens=50000`), ten `trigger=growth` nudges fired at 67K–152K tokens. Raising `nudgeGrowthTokens` is not a clean workaround because the same knob drives T2/T3 promotion. +- **Expected behavior**: + - T1 efficiency (growth) nudge = `currentTokens >= minNudgeContextPercent%×modelContext` AND `growth >= nudgeGrowthTokens` (AND `growth >= growthFloor`). + - T1 maximum nudge = `currentTokens >= maxContextLimit` (unchanged; bypasses the floor). + - Emergency (≥ `emergencyThresholdPercent`, default 98%) override unchanged. + - T2/T3 tier-promotion nudges remain independent of the floor (useful even when working context is small). +- **Impact**: users who tune the floor to defer compression get no such deferral for growth nudges; the model compresses early instead of at the configured floor, wasting compression cycles. + +## 2. Reproduction (if applicable) + +- **Environment**: opencode-acp 1.14.25, Node 22/24, 400K-context model. +- **Minimal reproduction steps**: + 1. Set `compress.minNudgeContextPercent` above the session's starting context (e.g. 37.5 on a 400K model = 150K floor). + 2. Let context grow by ≥ `nudgeGrowthTokens` while still below the floor. + 3. A `trigger=growth` nudge is injected despite `currentTokens < floor`. +- **Relevant configuration**: `compress.minNudgeContextPercent`, `compress.nudgeGrowthTokens`. + +## 3. Root cause + +- `computeShouldNudge` (external `context-compress-algorithms/trigger`) computes `shouldNudge = growthSinceLastNudge >= nudgeGrowthTokens || overMaxLimit`. `minNudgeContextPercent` is passed in but **ignored** (deprecated in the policy's param type). +- In `lib/messages/inject/inject.ts`, `nudgeAllowed = emergencyOverride || (decision.shouldNudge && growthSinceBaseline >= growthFloor)` — no context-size floor at all. So growth nudges fire at any context size once the growth threshold is met. +- **Why not `minContextLimit`**: the first revision gated on `overMinLimit`, but `minContextLimit` defaults to **80%** (`lib/config.ts:200`) and is documented as the "soft lower threshold for turn/iteration reminders" (README.md:328-331). Gating growth nudges on it would suppress ALL growth nudges below 80% for default users — effectively disabling compression for most of a session (flagged by @dog in issue #342). The correct floor is the low-default `minNudgeContextPercent` (15%). + +## 4. Constraints & Non-Goals + +- **Constraints**: + - The floor must default to a LOW value (`minNudgeContextPercent` = 15%) so default users still get growth nudges throughout a session. + - When the model context limit is unknown, the floor is unresolvable and growth nudges keep pre-#342 growth-only behavior (no accidental suppression). + - `overMaxLimit` and the emergency path must be unaffected (they bypass/override the floor). + - T2/T3 tier-promotion nudges must remain independent of the floor. + - No new dependencies; no change to the external `context-compress-algorithms` package (fix lives in ACP's `inject.ts`). + - No change to persisted state format or internal `dcp` tags. +- **Non-Goals** (out of scope): + - Percentage-based `nudgeGrowthTokens` (issue #300). + - The broader T1/T2/T3 decision-chain refactor (issue #300). + - A per-model `modelMinNudgeContextPercent` variant (the floor is a global percent for now; per-model is a follow-up enhancement). + - Making the max-limit path bypass the `growthFloor` cadence gate (pre-existing, documented anti-thrashing behavior — left as-is per issue #342 discussion). + +## 5. Acceptance Criteria (must be testable) + +- **Correctness**: + - [x] A T1 growth nudge does NOT fire when `currentTokens < minNudgeContextPercent%×modelContext` (even if `growth >= nudgeGrowthTokens` and `>= growthFloor`). + - [x] A T1 growth nudge DOES fire when `currentTokens >= floor` and `growth >= nudgeGrowthTokens` and `>= growthFloor`. + - [x] A T1 maximum nudge still fires when `currentTokens >= maxContextLimit` (bypasses the floor). + - [x] The emergency (≥98%) override still fires regardless of the floor. + - [x] When the model context limit is unknown, growth nudges keep pre-#342 behavior (floor unresolvable → no suppression). + - [x] T2/T3 tier-promotion nudges are unaffected by the floor. +- **Performance / Stability**: one floor check in the existing decision path — no measurable cost. +- **Regression**: + - [x] New/modified test cases added to test suite and passing (1035 tests, 0 failures). + - [x] Pre-existing test #27 (context 100K on a 1M model) updated into the [floor, max) range since the dormant 15% floor now suppresses below 150K. + - [x] Stale README/CONFIGURATION default docs corrected (45%/55% → 80%/80%). + +## 6. Proposed Approach + +- **Affected modules & entry files**: + - `lib/messages/inject/inject.ts` — compute `overMinNudgeFloor` from `minNudgeContextPercent` and add `(overMaxLimit || overMinNudgeFloor)` to the `nudgeAllowed` growth path. + - `lib/messages/inject/utils.ts` — revert the `minLimitResolved` addition (dead code in the floor approach). + - `tests/inject.test.ts` — 4 floor tests + 2 new (unresolvable model limit, T2 independence); fix pre-existing test #27. + - `README.md`, `CONFIGURATION.md` — default corrections (from the review commit, preserved). +- **Risks**: activating a previously-dormant field changes default behavior (growth nudges below 15% are now suppressed). Verified against the full suite; the only pre-existing test affected was #27 (100K context on a 1M model). +- **Rollback strategy**: revert the commits; no schema/config/data migrations. diff --git a/devlog/2026-08-28_min-gate-growth-nudges/WORKLOG.md b/devlog/2026-08-28_min-gate-growth-nudges/WORKLOG.md new file mode 100644 index 00000000..fc7a5278 --- /dev/null +++ b/devlog/2026-08-28_min-gate-growth-nudges/WORKLOG.md @@ -0,0 +1,123 @@ +# WORKLOG - Gate T1 growth nudges on the minNudgeContextPercent floor + +- Task ID: `2026-08-28_min-gate-growth-nudges` +- Home Repo: `opencode-acp` +- Status: Done +- Updated: 2026-08-28 + +> **2026-08-29 amendment**: the default floor was lowered **15% → 5%** (commit `f3a3fc8`, maintainer decision). See §7. + +## 1. Summary + +- **What was done**: added a growth-nudge floor to the T1 decision in `lib/messages/inject/inject.ts`. A growth nudge now requires the context to be at/above the **`minNudgeContextPercent` floor** (default 15% of the model context). `overMaxLimit` and the emergency override bypass the floor; when the model context limit is unknown the floor is unresolvable and growth nudges keep their pre-#342 growth-only behavior. T2/T3 tier-promotion nudges are untouched. +- **Why**: `computeShouldNudge()` (external `context-compress-algorithms/trigger`) only uses `overMinLimit` to pick the tips variant, so growth nudges fired well below any configured floor (issue #342: ten `trigger=growth` nudges at 67K–152K against a 150K minimum). `minNudgeContextPercent` (default 15) is the intended "don't nudge below this" knob and was previously a **no-op** (plumbed into `computeShouldNudge` but ignored by the policy). +- **Revision history (important)**: + 1. `0f35414` — first fix gated on `minContextLimit`. + 2. `f634222` (dual-agent review) — refined to only gate when `minContextLimit` resolves (`minLimitResolved`), and corrected the stale README/CONFIGURATION defaults (45%/55% → 80%/80%, matching `lib/config.ts` since v1.14.16). **Those README/CONFIGURATION fixes are preserved.** + 3. This commit — @dog flagged in issue #342 that `minContextLimit` defaults to **80%**, so gating growth nudges on it suppresses ALL growth nudges below 80% for default users (effectively disabling compression for most of a session). The floor was corrected to `minNudgeContextPercent` (15% default), low enough that default users still get compression throughout a session. The `minLimitResolved` mechanism was dropped (my approach uses `modelContextLimit` directly). +- **Behavior / compatibility changes**: YES — growth nudges below the `minNudgeContextPercent` floor are now suppressed. This activates a previously-dormant field. No change to persisted state format or internal `dcp` tags. +- **Risk level**: Low — one floor check in an existing decision path; max/emergency paths and T2/T3 are unaffected. + +## 2. Change Log + +### Commits + +| Commit | Description | +|--------|-------------| +| `0f35414` | fix: gate T1 growth nudges on minContextLimit (issue #342) — **superseded** | +| `f634222` | dual-agent review: `minLimitResolved` guard + README/CONFIGURATION default fixes — **README/CONFIGURATION fixes preserved** | +| `ecfe7f0` | fix: use `minNudgeContextPercent` (15%) as the growth-nudge floor, not `minContextLimit` (80% default) | +| `b71e10b` | docs: align README/CONFIGURATION/utils comment/test header with the floor redesign (re-pointed stale minContextLimit-gate references) | + +### Key Files + +- `lib/messages/inject/inject.ts` — `nudgeAllowed` now includes `(overMaxLimit || overMinNudgeFloor)` in the growth path, where `overMinNudgeFloor` is derived from `minNudgeContextPercent`. +- `lib/messages/inject/utils.ts` — reverted the `minLimitResolved` addition (dead code in the floor approach). +- `tests/inject.test.ts` — 4 floor tests (use `minNudgeContextPercent`); 2 new tests (unresolvable model limit, T2 independence); pre-existing test #27 raised into the [floor, max) range. +- `README.md`, `CONFIGURATION.md` — default corrections from the review commit (preserved); descriptions re-pointed to the floor redesign (`minContextLimit` = turn/iteration reminders only, `minNudgeContextPercent` = growth-nudge floor). +- `lib/messages/inject/utils.ts` — `@deprecated` comment on `minNudgeContextPercent` corrected (field is active; floor computed in `inject.ts`). +- `tests/inject.test.ts` — section header re-pointed from "minContextLimit gate" to "minNudgeContextPercent floor". + +## 3. Design & Implementation Notes + +- **Entry point / key function**: `injectCompressNudges()` in `lib/messages/inject/inject.ts`. +- **The change** (inject.ts, `nudgeAllowed`): + ```ts + const minNudgeFloorTokens = + modelContextLimit !== undefined + ? Math.round(((config.compress?.minNudgeContextPercent ?? 15) / 100) * modelContextLimit) + : undefined + const overMinNudgeFloor = + minNudgeFloorTokens === undefined || + currentTokens === undefined || + currentTokens >= minNudgeFloorTokens + const nudgeAllowed = + emergencyOverride || + (decision.shouldNudge && + (overMaxLimit || overMinNudgeFloor) && // issue #342: growth floor + growthSinceBaseline !== undefined && + growthSinceBaseline >= growthFloor) + ``` +- **Why `minNudgeContextPercent`, NOT `minContextLimit`**: + - `minContextLimit` / `maxContextLimit` both default to **80%** (`lib/config.ts:199-200`). The README documents `minContextLimit` as the "soft lower threshold for **turn/iteration reminders**" (README.md:328-331) — not a growth-nudge floor. Using it as a growth floor would disable compression below 80% for default users. + - `minNudgeContextPercent` (default **15**, `lib/config.ts:202`) is a percent-of-model-context floor, low enough that default users still get growth nudges throughout a session, while remaining configurable (e.g. set to 37.5 for a 150K floor on a 400K model). It was already plumbed into `computeShouldNudge` (inject.ts:297) but ignored by the external policy — so it was the natural, intended field. + - `overMaxLimit ||` keeps the strong max-limit alert working even when the floor is set above the context (defensive against misconfiguration). +- **Unresolvable floor**: when `modelContextLimit` is unknown (a model that doesn't report `limit.context`), the floor can't be computed, so `overMinNudgeFloor` stays `true` (no gate) — preserving pre-#342 growth-only behavior. This is the same intent as the review commit's `minLimitResolved` guard, implemented directly. +- **Why the fix lives in ACP, not the external package**: `context-compress-algorithms` is a shared, version-pinned dependency; the floor is ACP-specific policy (ACP owns `minNudgeContextPercent` semantics). No dependency bump. +- **Caveat**: `minNudgeContextPercent` is a global percent (no per-model `modelMinNudgeContextPercent` variant). A user who set a per-model `modelMinLimits` (like the #342 reporter) expresses the floor as a global percent instead; a per-model variant is a follow-up enhancement. +- **T2/T3 independence**: the tier-promotion nudges (inject.ts, `tierChecks` loop) use their own `nudgeGrowthTokens`/`growthFloor` cadence and never consult the floor — preserved (locked by a dedicated test). + +## 4. Testing & Verification + +### Build & Test Commands + +```sh +cd opencode-acp && npm install +npm run build +npm run typecheck +node --import tsx --test tests/*.test.ts +``` + +### Results + +- `npm run typecheck`: clean. +- `npm run build`: success; floor logic confirmed present in `dist/index.js` (`minNudgeFloorTokens`/`overMinNudgeFloor`), old `minGateOpen`/`overMaxLimit || overMinLimit` removed. +- `npm run test`: **1035 tests, 0 failures**. + +### New / adjusted tests (tests/inject.test.ts) + +| Test | Asserts | +|------|---------| +| `issue #342: growth nudge suppressed below the minNudgeContextPercent floor, fires once context crosses it` | Multi-turn: `minNudgeContextPercent=30` (300K floor) — 200K < 300K → suppressed (baseline + lastNudgeShownTokens preserved); 320K ≥ 300K + growth → fires. | +| `issue #342: full growth cycle baseline → nudge → compress → new baseline → nudge (floor open)` | Full cycle with the floor open; baseline resets on compress, nudge re-fires after new-baseline growth. | +| `issue #342: growth floor holds in production config (preserveRecentMessages > 0)` | §5.7.1 production-config requirement: floor suppresses below it with `preserveRecentMessages: 2` and compressible content present. | +| `issue #342: over-max nudge bypasses the growth floor` | Defensive: floor set above the context — over-max context still nudges (overMaxLimit bypass). | +| `issue #342: growth nudge still fires when the model context limit is unknown (floor unresolvable)` | Unknown model limit → floor unresolvable → pre-#342 growth-only behavior preserved (nudge fires). (Review-commit regression lock, re-pointed at the floor.) | +| `issue #342: T2 tier-promotion fires below the growth floor (independent of the floor)` | Floor set at 800K — T1 floor-suppressed, T2 fires on its own cadence (lastTier2NudgeTokens set). (Review-commit test, re-pointed at the floor.) | + +### Existing tests updated + +- `stale contextLimitAnchors ... (issue #27)` — context raised from 100K (10% of 1M) to 150K so it sits in the [15% floor, max-limit) range; the previously-dormant 15% floor now suppresses below 150K, so the original 100K context no longer satisfies the floor. + +### Note on `npm run format:check` + +Pre-existing repo-wide Prettier drift: the installed Prettier (3.9.5) reformats 412 files (including unmodified `lib/config.ts`, `lib/hooks.ts`). CI does not enforce format. New code matches the file's existing style (4-space, no-semi, double-quote); not reformatting to avoid a noisy repo-wide diff. + +## 5. Rollback Plan + +- Revert the commits; no schema/config/data migrations. + +## 6. Lessons Learned + +- **`minContextLimit` defaults to 80%, not 45%.** The README's `45%` example was stale (the real default is `"80%"` in `lib/config.ts:200` since v1.14.16). Never assume a documented example value is the default — check `config.ts`. The review commit corrected the docs; this commit corrects the code to match the intent. +- The first revision (gate on `minContextLimit`) was a **silent regression for default users**: it would have suppressed all growth nudges below 80%. The pre-existing test #27 (context 100K on a 1M model) only caught it because 100K < 150K floor — a test with context between 150K and 800K would have masked the regression. Always check the **default** config path, not just the configured path. +- `minNudgeContextPercent` was a dormant no-op field — the natural home for the growth floor. Activating a dormant field is lower-risk than repurposing a live one (`minContextLimit` also drives turn/iteration anchors). +- A dual-agent review pushed refinements onto the branch based on the original (minContextLimit) approach; the @dog feedback superseded them. Integrated the review's valuable non-conflicting work (README/CONFIGURATION fixes, the unresolvable-limit + T2-independence tests) while replacing the core gate. + +## 7. Amendment: default floor lowered 15% → 5% (maintainer decision) + +- **Commit**: `f3a3fc8` — `fix: lower default minNudgeContextPercent floor 15% -> 5%` +- **Why**: the 15% default was a silent, bug-level behavior change for the dominant user profile — **large-window models (e.g. 1M context)** with small baselines. Floor mechanics: the floor binds when `P% × W > baseline + 50K` (growth threshold). At 15% that binds on windows ≥ ~400–667K; on a 1M window every compress cycle is suppressed until 150K context usage. The typical working cycle (baseline 10–50K → grow +50K → nudge → max ~110K → compress → land 20–50K → repeat) never reaches 150K, so the nudge — and therefore compression — is starved and the working range shifts to [~40K, 150–200K], ~2× steady-state input tokens. Since compress resets `lastPerMessageNudgeTokens` to the post-compress size, the floor re-binds **every** cycle, not just the first. +- **Why 5%**: with the fixed 50K growth threshold, a 5% floor only binds when `5% × W > baseline + 50K`, i.e. windows ≥ ~1.2–2M for typical baselines — inert for essentially all real working cycles while still catching pathological tiny-window thrash via `minimum: 0` users opting out. Escape hatches: set it higher explicitly (15–30%) to wait for larger usage; `0` disables the floor entirely. +- **Files**: `lib/config.ts` (default 15→5), `lib/messages/inject/inject.ts` (policy passthrough + floor fallback `?? 5`, rationale comment), `dcp.schema.json` (default + stale description fixed), `CONFIGURATION.md`/`CONFIGURATION.zh-CN.md` (defaults + rationale), `tests/inject.test.ts` (2 comments re-pinned to the buildConfig factory value; new default-lock test). +- **Test**: `issue #342 follow-up: unset minNudgeContextPercent falls back to the low 5% default floor` — deletes the field from the config (fallback path), 1M window, baseline 50K, current 100K → asserts the nudge fires (100K ≥ 50K floor + 50K growth). Mutation-sensitive: reverting the fallback to `?? 15` puts the floor at 150K → suppressed → test fails (verified locally: 54/55 with mutation, 55/55 after restore; full suite 1036/1036, typecheck + build clean). diff --git a/devlog/2026-08-29_min-nudge-providers-models/REQ.md b/devlog/2026-08-29_min-nudge-providers-models/REQ.md new file mode 100644 index 00000000..95c797d7 --- /dev/null +++ b/devlog/2026-08-29_min-nudge-providers-models/REQ.md @@ -0,0 +1,81 @@ +# REQ - All-field per-provider/per-model overrides via nested providers/models config + +- Task ID: `2026-08-29_min-nudge-providers-models` +- Home Repo: `opencode-acp` +- Created: 2026-08-29 +- Status: InProgress +- Priority: P1 +- Owner: ranxianglei +- References: issue #344, PR #343 (floor gate + 5% default, MERGED — this PR is stacked on it), PR #345 (superseded flat-map design, closed), billion-context-pi `CONFIGURATION.zh-CN.md` §compress.providers (design reference) + +## 1. Background & Problem Statement + +- **Context**: Issue #344 wants per-model config: big-window models (e.g. 1M) need different nudge tuning than small-window ones, because a single global value binds at very different absolute token levels. +- **Current behavior**: #343 (merged) wires the global floor `compress.minNudgeContextPercent` (default 5%). PR #345 implemented per-model overrides as a **flat map** — rejected. The initial version of THIS PR shipped the nested structure for `minNudgeContextPercent` only. +- **Scope extension (2026-08-29, maintainer directive)**: “应该所有字段都需要三级别 而不是仅仅一个字段” — the nested cascade must cover **every tunable compress field**, not just the floor. +- **Expected behavior**: `compress.providers.{provider}.models.{model}.{field}` cascades **model > provider > global, field-by-field** for all overridable fields. +- **Impact**: Users get the documented bcp-style config shape for all tuning knobs in one place. + +## 2. Design (target config) + +```jsonc +{ + "compress": { + "minNudgeContextPercent": 5, // global (from #343, default 5) + "providers": { + "anthropic": { + "minNudgeContextPercent": 8, // provider-level override + "models": { + "claude-sonnet-4-6": { + "minNudgeContextPercent": 10 // model-level override (highest) + } + } + } + } + } +} +``` + +- Resolution: `providers[p].models[m].minNudgeContextPercent` → `providers[p].minNudgeContextPercent` → `compress.minNudgeContextPercent` (→ default 5). Unset fields do not clear shallower values. `0` is an explicit "disable the floor" (not "unset"). +- Percent resolves against the **active model's** context window; unknown window → floor undefined (growth-only, same as #343). +- Exact provider/model id match (from `getModelInfo`, last user message `info.model`); unknown provider/model falls back up the cascade. +- **All-field cascade (scope extension)**: the same resolution applies to every overridable field — `maxContextLimit`, `emergencyThresholdPercent`, `nudgeFrequency`, `iterationNudgeThreshold`, `toolOutputNudgeThreshold`, `nudgeGrowthTokens`, `minNudgeGrowthRatio`, `minNudgeGrowthFloor`, `nudgeForce`, `protectedTools`, `showCompression`, `summaryBuffer`, `protectTags`, `protectUserMessages`, `maxSummaryLengthHard`, `minCompressRange`, `maxVisibleSegments`, `keepEmbedMaxChars`, `lastSegmentSoftBlock`, `preserveRecentMessages`, `preserveRecentTokens`, `preserveLastUserMessage`. +- **Not overridable**: `permission` (session-level, fixed before model info is known), deprecated `minContextLimit` / `modelMinLimits` family, flat `modelMaxLimits` (legacy), `providers` itself. +- **maxContextLimit precedence**: nested override > `modelMaxLimits` flat map > global. The blanket apply path (`applyCompressOverrides`) explicitly EXCLUDES `maxContextLimit` so the explicit chain in `resolveContextTokenLimit` stays authoritative. +- **Mechanism**: `applyCompressOverrides(config, providerId, modelId)` returns a shallow-cloned effective config; entry points swap it in right after model info is known — `injectCompressNudges` (inject.ts) reassigns the config param, the compress tool (range.ts) rebuilds `ctx` after `prepareSession`. Identity return (same reference, zero alloc) when no override applies. +- **Exceptions documented**: the system-prompt protected-tools listing (hooks.ts, built before model info is available) always reflects the global `protectedTools`. +- Three-layer file merge (global → configDir → project): `providers` maps deep-merge **per key / per field** (a project layer can add one provider without wiping the global layer's other providers). This intentionally differs from the flat `modelMaxLimits` replace-inherit semantics — nested maps merge naturally. + +## 3. Constraints & Non-Goals + +- **Constraints**: + - Stacked on #343 (`2026-08-28_min-gate-growth-nudges`, merged); must not break its tests. + - AGENTS.md §5.7: nudge-logic changes need multi-turn tests + side-effect assertions. + - No `as any` / `@ts-ignore` (type cast through `unknown` is used only at the config swap boundary). +- **Non-Goals**: + - NOT absorbing/migrating existing flat `modelMaxLimits` / `modelMinLimits` (separate decision later). + - NOT making `permission` overridable (tool registration happens before model info exists). + - NOT touching the deprecated min family (deprecation handled by PR #352). + +## 4. Acceptance Criteria + +- **Correctness**: + - [x] `resolveMinNudgeFloorTokens(config, modelContextLimit, providerId, modelId)` returns model > provider > global cascade, clamped 0–100, rounded; undefined context → undefined. + - [x] inject gate uses resolver; policy passthrough sees the effective percent. + - [x] `resolveCompressOverrides` merges provider then model fields (model wins per field); `applyCompressOverrides` swaps the effective config at both entry points, excludes `maxContextLimit`, identity when nothing applies. + - [x] `resolveContextTokenLimit` honors nested `maxContextLimit` with precedence nested > flat `modelMaxLimits` > global. + - [x] Config merge: L2/L3 deep-merge per provider/model key; deepClone isolates. + - [x] Validation rejects: non-object providers, wrong-typed field values at both levels (per-field type table), unknown fields, non-object models entries. + - [x] dcp.schema.json + CONFIGURATION.md + CONFIGURATION.zh-CN.md document the nested shape with the full field list. + - [x] Tests: resolver cascade (model beats provider beats global, unknown falls back, 0 disables), inject multi-turn (floor suppressed→fired, over-max band per model, growth threshold per provider), merge tests, validation tests. Mutation-verified (M1–M5). +- **Performance**: O(1) lookups per turn; no measurable overhead. + +## 5. Plan + +1. `lib/config.ts`: `CompressModelOverrides` / `CompressProviderOverrides` types, `CompressConfig.providers`, merge helpers, deepClone. +2. `lib/messages/inject/utils.ts`: `resolveMinNudgeContextPercent` + `resolveMinNudgeFloorTokens` exports. +3. `lib/messages/inject/inject.ts`: use resolver (floor tokens + effective percent for policy). +4. `lib/config-validation.ts`: `validateProviderOverrides`. +5. `dcp.schema.json`, CONFIGURATION.md, CONFIGURATION.zh-CN.md. +6. Tests (inject + config merge + validation), typecheck, full suite, build. +7. Push, PR, close #345 as superseded. diff --git a/devlog/2026-08-29_min-nudge-providers-models/WORKLOG.md b/devlog/2026-08-29_min-nudge-providers-models/WORKLOG.md new file mode 100644 index 00000000..7f90e7da --- /dev/null +++ b/devlog/2026-08-29_min-nudge-providers-models/WORKLOG.md @@ -0,0 +1,125 @@ +# WORKLOG — 2026-08-29_min-nudge-providers-models + +## 1. Overview + +Replaces PR #345's flat `compress.modelMinNudgeLimits` map (`"provider/model"` string keys) with a nested `compress.providers.{provider}.models.{model}.minNudgeContextPercent` structure, cascading field-by-field as **model > provider > global** — the same design as the sibling project [billion-context-pi](https://github.com/ranxianglei/billion-context-pi/blob/master/CONFIGURATION.zh-CN.md). + +- Stacked on PR #343 (`2026-08-28_min-gate-growth-nudges`) — the per-model floor rides on #343's `nudgeAllowed` gate. +- Supersedes PR #345 (`2026-08-28_model-min-nudge-limits`) — that PR is closed unmerged; its config surface never shipped, so there is zero migration cost. + +## 2. Changes + +### `lib/config.ts` +- New exported types: `CompressModelOverrides` (`{ minNudgeContextPercent?: number }`) and `CompressProviderOverrides` (extends it with `models?: Record`). +- `CompressConfig.providers?: Record`. +- `mergeCompress` deep-merges providers across config file layers via `mergeProviderOverrides` / `mergeModelOverrides` / `stripUndefined` — per provider id, per model id, field-by-field. A project layer can narrow one provider without wiping others from lower layers (intentionally different from the flat maps' replace-inherit). +- `deepCloneConfig` cloned out as `export` (used by tests) and extended to clone the nested `providers` structure. + +### `lib/messages/inject/utils.ts` +- `DEFAULT_MIN_NUDGE_CONTEXT_PERCENT = 5` (shared with inject.ts's #343 default). +- `resolveMinNudgeContextPercent(config, providerId?, modelId?): number | undefined` — the cascade; `0` is an explicit disable, unset returns `undefined`. +- `resolveMinNudgeFloorTokens(config, modelContextLimit, providerId?, modelId?): number | undefined` — clamps 0–100, rounds, × window; `undefined` when the window is unknown (floor stays open, growth-only — #343 semantics). + +### `lib/messages/inject/inject.ts` +- Floor computation and policy passthrough now call the resolvers instead of reading `config.compress.minNudgeContextPercent` directly. + +### `lib/config-validation.ts` +- `compress.providers` recognized as a dynamic-key path (like `modelMaxLimits`). +- `validatePercentField` / `validateModelOverrides` / `validateProviderOverrides`: percent must be a finite number 0–100; unknown fields rejected (typos like `minNudgeContextPecent` caught); models must be an object of override objects. + +### `dcp.schema.json` +- `compress.providers` schema: provider objects allow `minNudgeContextPercent` + `models`; model objects allow `minNudgeContextPercent` only; `additionalProperties: false` at both levels. + +### Docs +- `CONFIGURATION.md` + `CONFIGURATION.zh-CN.md`: new `compress.providers` section (semantics + example), `minNudgeContextPercent` cross-pointer, new recipe "Per-model growth-nudge floor". + +## 3. Tests — 1053/1053 green (1036 baseline + 17 new) + +- `tests/inject.test.ts` (+8): resolver cascade/precedence/fallback/0-disable/clamp/window-unknown; gate tests are multi-turn with side-effect assertions (§5.7): model-level 30% floor suppresses then fires, provider-level 25%, unknown provider → global, model-level 0 disables. +- `tests/config-providers.test.ts` (new, +9): mergeCompress deep-merge across layers, cannot-clear semantics, deepCloneConfig isolation, validation accept/reject matrix. + +## 4. Mutation verification (§5.7.3) + +| Mutation | Expected signal | Result | +|---|---|---| +| M1: skip model level in cascade | model-level resolver/gate tests fail | exactly 5 fail ✓ | +| M2: default `?? 5` → `?? 15` | default-floor tests fail | exactly 2 fail (#342 default-lock + new #344 default) ✓ | +| M3: deep-merge → replace | merge tests fail | exactly 2 fail (cannot-clear + deep-merge) ✓ | + +## 5. Process incident + +During the first mutation run, `git checkout` was used to revert a mutation — but the implementation was uncommitted, so the checkout wiped the new code in `lib/messages/inject/utils.ts` and `lib/config.ts`. Re-applied both files and re-verified (typecheck ✓, 1053/1053 ✓). Lesson recorded: mutation testing must back up files (`cp` → mutate → restore from copy), never `git checkout`, while work is uncommitted. + +## 6. Review fixes (2026-08-29, post-PR review) + +1. **§5.7.1 gap — no new test used a production config.** All 17 original tests ran on `buildConfig()` defaults (`preserveRecentMessages: 0`), which disables the protected-zone / `nothingToCompress` path — the exact scenario behind the #207 baseline-reset bug. Added `issue #344: per-model floor holds in production config across the full growth cycle (preserveRecentMessages > 0)` to `tests/inject.test.ts`: 5 turns sharing one `SessionState` with `preserveRecentMessages: 2`, covering floor suppression → fire → compress → new baseline → fire again, plus a fully-protected turn that locks the #207 regression (baseline NOT reset by `nothingToCompress`; `lastNudgeShownTokens` kept). Mutation-verified: reintroducing the #207 bug (baseline reset on `nothingToCompress`) fails this test (and the 5 pre-existing #207 regression tests). +2. **Unrelated `.gitignore` change reverted.** The `node_modules/` → `node_modules` edit was bundled into `e14193c` with a "symlink fix" rationale that is not substantiated anywhere in the repo (no tracked symlink, no issue reference). Reverted to keep the PR scoped to #344. + +Test count after fixes: 1054/1054 (1036 baseline + 18 new). + +## 7. Commits + +| Hash | Subject | +|---|---| +| `e14193c` | feat: per-provider/per-model growth-nudge floor via nested compress.providers (issue #344) | +| `58043e0` | docs: WORKLOG commit table for e14193c | +| `e2f4f90` | feat: extend nested providers cascade to ALL compress fields (issue #344 follow-up) | +| `6576fcf` | review: §5.7.1 production-config growth-cycle test + revert unrelated .gitignore change (reviewer commit, merged) | + +--- + +# Extension: all-field cascade (2026-08-29, same PR) + +Maintainer directive: "应该所有字段都需要三级别 而不是仅仅一个字段" — every tunable compress field gets the nested cascade, not just `minNudgeContextPercent`. + +## E1. Implementation + +- `lib/config.ts`: `CompressOverridableConfig = Omit`; `CompressModelOverrides = Partial`; `CompressProviderOverrides` unchanged shape (`models?` sub-map). Merge helpers unchanged (they were already field-generic). +- `lib/messages/inject/utils.ts`: + - `resolveCompressOverrides(config, providerId?, modelId?)` — merged override set (provider fields spread, model entry spread on top; `models` key stripped). + - `applyCompressOverrides(config, providerId?, modelId?)` — shallow-clones `{...config, compress: {...compress, ...applied}}`; **excludes `maxContextLimit`** (explicit chain below stays authoritative); identity (same reference) when nothing applies. + - `resolveContextTokenLimit` now exported; honors nested `maxContextLimit` with precedence **nested > flat `modelMaxLimits` > global** (min threshold has no nested override — deprecated family). +- `lib/messages/inject/inject.ts`: right after `getModelInfo`, `config = applyCompressOverrides(config, providerId, modelId)` — param reassignment, all downstream reads (getNudgeFrequency, iterationNudgeThreshold, nudgeForce, summaryBuffer, nudgeGrowthTokens, growthFloor inputs, emergency threshold, protectedTools, preserve*, lastSegmentSoftBlock, resolveEffectiveFloor) pick up the cascade. +- `lib/compress/range.ts`: after `prepareSession`, `ctx = {...ctx0, config: applyCompressOverrides(ctx0.config, providerId, modelId)}` via `getModelInfo(rawMessages)`; early validation (`maxLen`) stays on `ctx0` (model info not yet available). +- `lib/config-validation.ts`: per-field type table `OVERRIDE_FIELD_TYPES` (boolean / nonNegativeNumber / positiveNumber / percent / limit / nudgeForce / stringArray) covering all 24 overridable fields; generic unknown-field rejection at both levels; `validatePercentField` / `validateLimitValue` reused for exact error parity. +- `dcp.schema.json`: `providers` nested schema lists all 23 model-level / 24 provider-level (+`models`) properties, `additionalProperties: false`. + +## E2. Documented exceptions + +- `permission`: not overridable (tool registration precedes model info). +- Deprecated `minContextLimit` / `modelMinLimits`: no nested surface (PR #352 deprecates them). +- `maxContextLimit`: nested beats the flat map (explicit precedence chain). +- System-prompt protected-tools listing (hooks.ts:128): global value only (prompt build precedes model info). + +## E3. Tests — 1061/1061 green (+8) + +- `tests/config-providers.test.ts` (+6): all-field resolver cascade (model wins per field, sibling/provider-only/unknown), identity swap, swap-excludes-maxContextLimit + input not mutated, nested-max precedence over flat map, multi-field validation accept matrix, wrong-typed rejects at both levels (nudgeForce enum, protectedTools array, positiveNumber, boolean, limit string, structural non-overridables). +- `tests/inject.test.ts` (+2, §5.7 multi-turn + side-effect assertions): model-level `maxContextLimit` lowers the over-max band (baseline 220K → growth 30K: ≥22.5K growthFloor, <50K threshold, so ONLY over-max can fire; turn 2 same context on unknown provider → no nudge); provider-level `nudgeGrowthTokens` tightens the growth threshold (8K growth fires at 5K override; same growth on unknown provider suppressed at 50K default). + +## E4. Mutation verification (all exact) + +| Mutation | Result | +|---|---| +| M1: skip model level | 9 fail (resolver + floor gates + new cascade tests) ✓ | +| M2: default 5 → 15 | exactly 2 fail ✓ | +| M3: deep-merge → replace | exactly 2 fail ✓ | +| M4: remove nested-max block in resolveContextTokenLimit | exactly 2 fail (precedence test + over-max band inject test) ✓ | +| M5: remove inject.ts config swap | exactly 1 fail (growth-threshold override test) ✓ | + +Design note on M4: the first version of the over-max band test used `lastPerMessageNudgeTokens = 0` — the growth path fired anyway (non-discriminating; it survived M4). Fixed by raising the baseline to 220K so growth (30K) sits between growthFloor (22.5K) and the threshold (50K): only the over-max path can fire. + +## E5. Docs + +- CONFIGURATION.md / CONFIGURATION.zh-CN.md: `compress.providers` section rewritten — full overridable field list, not-overridable list, maxContextLimit precedence note, system-prompt exception, multi-field example; new recipe "Per-model tuning of any compress field" (EN+zh). + +## E6. Merge with reviewer commit 6576fcf + +- Reviewer pushed `6576fcf` to the PR branch: a §5.7.1 production-config growth-cycle test (baseline → growth → nudge → compress → new baseline → growth → nudge + PR #207 `nothingToCompress` regression lock, `preserveRecentMessages: 2`) and a revert of the unrelated `.gitignore` tweak from `e14193c`. +- Merged (no force-push, reviewer commit preserved). Conflicts in `tests/inject.test.ts` (both sides appended tests at the same point, shared preamble) and this file; resolved by keeping BOTH sides — the 2 all-field cascade tests AND the reviewer's growth-cycle test. Suite re-run below. +- Test totals after merge: `tests/inject.test.ts` now carries 3 additional tests on top of the pre-merge 65 (2 mine + 1 reviewer's). + +## E7. README updates (maintainer request: "readme 中英文两个都需要更新") + +- README.md + README.zh-CN.md: + - Default-config jsonc: added commented `compress.providers` nested example (after `modelMinLimits`), noting per-field model > provider > global resolution and global fallback for unknown IDs. + - New section "Per-Provider / Per-Model Overrides" (EN) / "按 Provider / 按模型覆盖" (zh) between the config details block and Prompt Overrides: multi-field jsonc example, per-field precedence, nested `maxContextLimit` > flat `modelMaxLimits`, 3-layer deep-merge, non-overridable exceptions, pointer to CONFIGURATION.md#compressproviders (23-field list). diff --git a/lib/compress/range.ts b/lib/compress/range.ts index 1465b038..724666db 100644 --- a/lib/compress/range.ts +++ b/lib/compress/range.ts @@ -38,6 +38,7 @@ import { } from "./state" import type { CompressRangeToolArgs } from "./types" import { resolveKeepMarkers } from "./keep-markers" +import { getModelInfo, applyCompressOverrides } from "../messages/inject/utils" import { buildQualityRejectionError, evaluatePreCommitQuality, @@ -102,13 +103,13 @@ export function createCompressRangeTool(factoryCtx: ToolFactoryContext): ReturnT description: runtimePrompts.compressRange + RANGE_FORMAT_EXTENSION, args: buildSchema(factoryCtx.config.compress.maxSummaryLengthHard), async execute(args, toolCtx) { - const ctx = resolveToolContext(factoryCtx, toolCtx.sessionID) + const ctx0 = resolveToolContext(factoryCtx, toolCtx.sessionID) const input = args as CompressRangeToolArgs validateArgs(input) const maxLen = (args as { summaryMaxChars?: number }).summaryMaxChars ?? - ctx.config.compress.maxSummaryLengthHard + ctx0.config.compress.maxSummaryLengthHard for (const entry of input.content) { if (entry.summary.length > maxLen) { throw new Error( @@ -123,10 +124,20 @@ export function createCompressRangeTool(factoryCtx: ToolFactoryContext): ReturnT : undefined const { rawMessages, searchContext } = await prepareSession( - ctx, + ctx0, toolCtx, `Compress Range: ${input.topic ?? "(batch)"}`, ) + + // Three-level cascade (issue #344): once the session messages are + // loaded, swap in the effective compress config for the active + // provider/model so every ctx.config.compress.X read below picks up + // per-model > per-provider > global resolution. + const { providerId, modelId } = getModelInfo(rawMessages) + const ctx: typeof ctx0 = { + ...ctx0, + config: applyCompressOverrides(ctx0.config, providerId, modelId), + } const resolvedPlans = resolveRanges(input, searchContext, ctx.state, ctx.logger) validateNonOverlapping(resolvedPlans) diff --git a/lib/config-validation.ts b/lib/config-validation.ts index 27ba9016..33c1e56b 100644 --- a/lib/config-validation.ts +++ b/lib/config-validation.ts @@ -28,6 +28,7 @@ export const VALID_CONFIG_KEYS = new Set([ "compress.minContextLimit", "compress.modelMaxLimits", "compress.modelMinLimits", + "compress.providers", "compress.nudgeFrequency", "compress.minNudgeContextPercent", "compress.nudgeGrowthTokens", @@ -76,6 +77,7 @@ function getConfigKeyPaths(obj: Record, prefix = ""): string[] { if ( fullKey === "compress.modelMaxLimits" || fullKey === "compress.modelMinLimits" || + fullKey === "compress.providers" || fullKey === "messageFilters.filters" ) { continue @@ -607,6 +609,194 @@ export function validateConfigTypes(config: Record): ValidationErro validateModelLimits("compress.modelMaxLimits", compress.modelMaxLimits) validateModelLimits("compress.modelMinLimits", compress.modelMinLimits) + const validatePercentField = (key: string, value: unknown): void => { + if (value === undefined) { + return + } + if ( + typeof value !== "number" || + !Number.isFinite(value) || + value < 0 || + value > 100 + ) { + errors.push({ + key, + expected: "number (0-100)", + actual: JSON.stringify(value), + }) + } + } + + // Per-provider / per-model override validation (compress.providers, + // issue #344). The override surface mirrors CompressModelOverrides in + // lib/config.ts: every `compress` field except the structural / + // session-level ones (permission, the deprecated minContextLimit + // family, the flat model*Limits maps, providers itself). + type OverrideFieldType = + | "boolean" + | "nonNegativeNumber" + | "positiveNumber" + | "percent" + | "limit" + | "nudgeForce" + | "stringArray" + + const OVERRIDE_FIELD_TYPES: Record = { + showCompression: "boolean", + summaryBuffer: "boolean", + protectTags: "boolean", + protectUserMessages: "boolean", + lastSegmentSoftBlock: "boolean", + preserveLastUserMessage: "boolean", + maxContextLimit: "limit", + emergencyThresholdPercent: "limit", + minNudgeContextPercent: "percent", + nudgeFrequency: "positiveNumber", + iterationNudgeThreshold: "positiveNumber", + toolOutputNudgeThreshold: "nonNegativeNumber", + nudgeGrowthTokens: "nonNegativeNumber", + minNudgeGrowthRatio: "nonNegativeNumber", + minNudgeGrowthFloor: "nonNegativeNumber", + nudgeForce: "nudgeForce", + protectedTools: "stringArray", + maxSummaryLengthHard: "positiveNumber", + minCompressRange: "nonNegativeNumber", + maxVisibleSegments: "positiveNumber", + keepEmbedMaxChars: "nonNegativeNumber", + preserveRecentMessages: "nonNegativeNumber", + preserveRecentTokens: "nonNegativeNumber", + } + + const validateOverrideField = (key: string, type: OverrideFieldType, value: unknown): void => { + if (value === undefined) { + return + } + switch (type) { + case "boolean": + if (typeof value !== "boolean") { + errors.push({ key, expected: "boolean", actual: typeof value }) + } + break + case "nonNegativeNumber": + case "positiveNumber": { + const min = type === "positiveNumber" ? 1 : 0 + if ( + typeof value !== "number" || + !Number.isFinite(value) || + value < min + ) { + errors.push({ + key, + expected: type === "positiveNumber" ? "number (>= 1)" : "number (>= 0)", + actual: JSON.stringify(value), + }) + } + break + } + case "percent": + validatePercentField(key, value) + break + case "limit": + validateLimitValue(key, value as number | `${number}%`) + break + case "nudgeForce": + if (value !== "strong" && value !== "soft") { + errors.push({ key, expected: "'strong' | 'soft'", actual: JSON.stringify(value) }) + } + break + case "stringArray": + if ( + !Array.isArray(value) || + !value.every((entry) => typeof entry === "string") + ) { + errors.push({ key, expected: "string[]", actual: JSON.stringify(value) }) + } + break + } + } + + const validateOverrideObject = (prefix: string, overrides: unknown): void => { + if (overrides === undefined) { + return + } + if (typeof overrides !== "object" || overrides === null || Array.isArray(overrides)) { + errors.push({ key: prefix, expected: "CompressModelOverrides", actual: typeof overrides }) + return + } + const model = overrides as Record + for (const field of Object.keys(model)) { + const type = OVERRIDE_FIELD_TYPES[field] + if (type === undefined) { + errors.push({ + key: `${prefix}.${field}`, + expected: "known override field (see CompressModelOverrides)", + actual: "unknown field", + }) + continue + } + validateOverrideField(`${prefix}.${field}`, type, model[field]) + } + } + + // compress.providers..models. — dynamic keys at two + // levels, so key-path walking skips it (see getConfigKeyPaths) and this + // structural validation rejects unknown fields instead (typo safety). + const validateModelOverrides = validateOverrideObject + + const validateProviderOverrides = (providers: unknown): void => { + if (providers === undefined) { + return + } + if (typeof providers !== "object" || providers === null || Array.isArray(providers)) { + errors.push({ + key: "compress.providers", + expected: "Record", + actual: typeof providers, + }) + return + } + 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 }) + continue + } + const provider = providerValue as Record + for (const field of Object.keys(provider)) { + if (field === "models") { + continue + } + const type = OVERRIDE_FIELD_TYPES[field] + if (type === undefined) { + errors.push({ + key: `${prefix}.${field}`, + expected: "known override field (see CompressModelOverrides)", + actual: "unknown field", + }) + continue + } + validateOverrideField(`${prefix}.${field}`, type, provider[field]) + } + const models = provider.models + if (models === undefined) { + continue + } + if (typeof models !== "object" || models === null || Array.isArray(models)) { + errors.push({ + key: `${prefix}.models`, + expected: "Record", + actual: typeof models, + }) + continue + } + for (const [modelId, modelValue] of Object.entries(models)) { + validateModelOverrides(`${prefix}.models.${modelId}`, modelValue) + } + } + } + + validateProviderOverrides(compress.providers) + 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 2a34c660..e44bf984 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -9,6 +9,33 @@ import type { LogLevel } from "./logger" type Permission = "ask" | "allow" | "deny" +/** + * The subset of `CompressConfig` fields that can be overridden per provider / + * per model via `compress.providers` (resolved field-by-field: + * model > provider > global). + * + * Excluded: + * - `permission` — resolved at tool registration time, before any model info exists + * - `minContextLimit` / `modelMinLimits` — deprecated (see CONFIGURATION.md) + * - `modelMaxLimits`, `modelMinLimits`, `providers` — structural (maps themselves) + */ +export type CompressOverridableConfig = Omit< + CompressConfig, + "permission" | "minContextLimit" | "modelMaxLimits" | "modelMinLimits" | "providers" +> + +/** Per-model / per-provider override object (all overridable fields optional). */ +export type CompressModelOverrides = Partial + +/** + * Per-provider overrides inside `compress.providers.`. Provider-level + * fields apply to every model of that provider unless narrowed by a + * `models.` entry (which wins field-by-field). + */ +export interface CompressProviderOverrides extends CompressModelOverrides { + models?: Record +} + export interface CompressConfig { permission: Permission showCompression: boolean @@ -17,6 +44,8 @@ export interface CompressConfig { minContextLimit: number | `${number}%` modelMaxLimits?: Record modelMinLimits?: Record + /** Nested per-provider / per-model overrides (billion-context-pi style). Resolved field-by-field: model > provider > global. */ + providers?: Record nudgeFrequency: number minNudgeContextPercent: number nudgeGrowthTokens?: number @@ -199,7 +228,7 @@ const defaultConfig: PluginConfig = { maxContextLimit: "80%", minContextLimit: "80%", nudgeFrequency: 5, - minNudgeContextPercent: 15, + minNudgeContextPercent: 5, iterationNudgeThreshold: 15, nudgeForce: "soft", protectedTools: [...COMPRESS_DEFAULT_PROTECTED_TOOLS], @@ -354,6 +383,61 @@ function loadConfigFile(configPath: string): ConfigLoadResult { } } +/** Strip undefined-valued keys so a partial override never clears inherited fields. */ +function stripUndefined(value: T): T { + const result: Record = {} + for (const [key, entry] of Object.entries(value)) { + if (entry !== undefined) { + result[key] = entry + } + } + return result as T +} + +/** + * Merge model-level override maps across config layers, per model id, + * field-by-field: an explicitly-set field in the override layer wins; unset + * fields inherit from the base layer. + */ +function mergeModelOverrides( + base: Record | undefined, + override: Record | undefined +): Record | undefined { + if (base === undefined) return override + if (override === undefined) return base + const merged: Record = { ...base } + for (const [modelId, model] of Object.entries(override)) { + merged[modelId] = { ...base[modelId], ...stripUndefined(model) } + } + return merged +} + +/** + * Merge provider override maps across config layers, per provider id, then per + * model id, field-by-field. Unlike the flat modelMaxLimits/modelMinLimits maps + * (replace-inherited), the nested providers structure deep-merges: a project + * layer can narrow one provider without wiping the other providers configured + * in lower layers. + */ +function mergeProviderOverrides( + base: Record | undefined, + override: Record | undefined +): Record | undefined { + if (base === undefined) return override + if (override === undefined) return base + const merged: Record = { ...base } + for (const [providerId, provider] of Object.entries(override)) { + const baseProvider = base[providerId] + const mergedModels = mergeModelOverrides(baseProvider?.models, provider.models) + merged[providerId] = { + ...baseProvider, + ...stripUndefined(provider), + ...(mergedModels !== undefined ? { models: mergedModels } : {}), + } + } + return merged +} + export function mergeCompress( base: PluginConfig["compress"], override?: CompressOverride, @@ -370,6 +454,7 @@ export function mergeCompress( minContextLimit: override.minContextLimit ?? base.minContextLimit, modelMaxLimits: override.modelMaxLimits ?? base.modelMaxLimits, modelMinLimits: override.modelMinLimits ?? base.modelMinLimits, + providers: mergeProviderOverrides(base.providers, override.providers), nudgeFrequency: override.nudgeFrequency ?? base.nudgeFrequency, minNudgeContextPercent: override.minNudgeContextPercent ?? base.minNudgeContextPercent, nudgeGrowthTokens: override.nudgeGrowthTokens, @@ -420,7 +505,7 @@ function mergeExperimental( } } -function deepCloneConfig(config: PluginConfig): PluginConfig { +export function deepCloneConfig(config: PluginConfig): PluginConfig { return { ...config, commands: { @@ -433,6 +518,26 @@ function deepCloneConfig(config: PluginConfig): PluginConfig { ...config.compress, modelMaxLimits: { ...config.compress.modelMaxLimits }, modelMinLimits: { ...config.compress.modelMinLimits }, + providers: config.compress.providers + ? Object.fromEntries( + Object.entries(config.compress.providers).map(([providerId, provider]) => [ + providerId, + { + ...provider, + ...(provider.models + ? { + models: Object.fromEntries( + Object.entries(provider.models).map(([modelId, model]) => [ + modelId, + { ...model }, + ]) + ), + } + : {}), + }, + ]) + ) + : undefined, protectedTools: [...config.compress.protectedTools], }, gc: { diff --git a/lib/messages/inject/inject.ts b/lib/messages/inject/inject.ts index 390f0a29..35f96a12 100644 --- a/lib/messages/inject/inject.ts +++ b/lib/messages/inject/inject.ts @@ -40,6 +40,10 @@ import { getModelInfo, isContextOverLimits, DEFAULT_NUDGE_GROWTH_TOKENS, + DEFAULT_MIN_NUDGE_CONTEXT_PERCENT, + resolveMinNudgeContextPercent, + resolveMinNudgeFloorTokens, + applyCompressOverrides, } from "./utils" import { buildCompressedBlockGuidance } from "../../prompts/extensions/nudge" import { COMPRESS_PHILOSOPHY, HOW_TO_COMPRESS_RULES, TIER2_DISTILL_RULES, TIER3_CONDENSE_RULES } from "context-compress-algorithms/prompts" @@ -86,6 +90,14 @@ export const injectCompressNudges = ( const { providerId, modelId } = getModelInfo(messages) + // Three-level cascade (issue #344): swap in the effective compress config + // for this provider/model so every config.compress.X read below (growth + // thresholds, nudge frequency, protected tools, summary buffer, …) picks + // up per-model > per-provider > global resolution. maxContextLimit is + // resolved separately inside resolveContextTokenLimit (nested > flat map + // > global). + config = applyCompressOverrides(config, providerId, modelId) + const { overMaxLimit, overMinLimit, currentTokens, modelContextLimit } = isContextOverLimits( config, state, @@ -294,7 +306,7 @@ export const injectCompressNudges = ( overMinLimit, overMaxLimit, lastNudgeTokens: growthReference, - minNudgeContextPercent: config.compress?.minNudgeContextPercent ?? 15, + minNudgeContextPercent: resolveMinNudgeContextPercent(config, providerId, modelId) ?? DEFAULT_MIN_NUDGE_CONTEXT_PERCENT, nudgeGrowthTokens: effectiveThreshold, }) @@ -302,9 +314,40 @@ export const injectCompressNudges = ( currentTokens !== undefined && growthReference !== undefined ? currentTokens - growthReference : undefined + // Issue #342: a growth nudge must not fire below the configured floor. + // The floor is minNudgeContextPercent (default 5% of the model context), + // NOT minContextLimit (default 80%) — minContextLimit is documented as the + // "soft lower threshold for turn/iteration reminders" (README), and using it + // as a growth floor would suppress ALL growth nudges below 80% for default + // users, effectively disabling compression for most of a session. + // minNudgeContextPercent is the intended "don't nudge below this" floor and + // was previously a no-op (passed to the trigger policy but ignored). When the + // model context limit is unknown the floor cannot be computed, so the gate + // stays open (pre-#342 growth-only behavior). overMaxLimit and the emergency + // override bypass the floor; T2/T3 tier-promotion nudges below are unaffected. + // The default is deliberately LOW (5%): with the default nudgeGrowthTokens + // (50K), a growth nudge's current tokens are always >= baseline+50K, and a + // 5% floor only binds when 5% x window > baseline+50K (i.e. windows >= ~2M + // for typical baselines). A 15% default would bind on >=400K windows and + // shift every compress cycle's working range upward (~2x average context + // on 1M-window models) — a silent, bug-level behavior change for + // large-window users. Users who want a higher floor set it explicitly, + // globally or per provider/model via compress.providers (issue #344: + // model > provider > global cascade, resolved by resolveMinNudgeFloorTokens). + const minNudgeFloorTokens = resolveMinNudgeFloorTokens( + config, + modelContextLimit, + providerId, + modelId + ) + const overMinNudgeFloor = + minNudgeFloorTokens === undefined || + currentTokens === undefined || + currentTokens >= minNudgeFloorTokens const nudgeAllowed = emergencyOverride || (decision.shouldNudge && + (overMaxLimit || overMinNudgeFloor) && growthSinceBaseline !== undefined && growthSinceBaseline >= growthFloor) diff --git a/lib/messages/inject/utils.ts b/lib/messages/inject/utils.ts index aa07b01e..b5a7e07f 100644 --- a/lib/messages/inject/utils.ts +++ b/lib/messages/inject/utils.ts @@ -1,5 +1,5 @@ import type { SessionState, WithParts } from "../../state" -import type { PluginConfig } from "../../config" +import type { PluginConfig, CompressConfig, CompressModelOverrides } from "../../config" import { messageContainsProtectedTool } from "../../compress/protected-content" import { isToolNameProtected, getFilePathsFromParameters, isFilePathProtected } from "../../protected-patterns" import { @@ -102,7 +102,7 @@ export function getModelInfo(messages: WithParts[]): LastUserModelContext { } } -function resolveContextTokenLimit( +export function resolveContextTokenLimit( config: PluginConfig, state: SessionState, providerId: string | undefined, @@ -132,6 +132,17 @@ function resolveContextTokenLimit( return Math.round((clampedPercent / 100) * state.modelContextLimit) } + // Per-provider / per-model nested override (compress.providers, issue #344): + // highest precedence for the max threshold — beats the legacy flat + // modelMaxLimits map, which in turn beats the global value. (The min + // threshold has no nested override: minContextLimit is deprecated.) + if (threshold === "max") { + const nestedLimit = resolveCompressOverrides(config, providerId, modelId).maxContextLimit + if (nestedLimit !== undefined) { + return parseLimitValue(nestedLimit) + } + } + const modelLimits = threshold === "max" ? config.compress.modelMaxLimits : config.compress.modelMinLimits if (modelLimits && providerId !== undefined && modelId !== undefined) { @@ -221,7 +232,7 @@ export function computeShouldNudge(params: { overMinLimit: boolean overMaxLimit: boolean lastNudgeTokens: number | undefined - /** @deprecated Kept for backward compat; ignored. Cadence is growth-only now. */ + /** Passed through to the trigger policy (currently ignored by it). The growth-nudge floor derived from this field is computed separately in inject.ts (minNudgeContextPercent × model context). */ minNudgeContextPercent: number nudgeGrowthTokens: number }): NudgeDecision { @@ -234,6 +245,126 @@ export function computeShouldNudge(params: { export const DEFAULT_NUDGE_GROWTH_TOKENS = 50_000 +/** Default growth-nudge floor percent when unset everywhere (#343: deliberately low — see inject.ts rationale). */ +export const DEFAULT_MIN_NUDGE_CONTEXT_PERCENT = 5 + +/** + * Resolve the effective growth-nudge floor percent for the active model, + * cascading field-by-field (billion-context-pi style): + * + * compress.providers[providerId].models[modelId].minNudgeContextPercent + * → compress.providers[providerId].minNudgeContextPercent + * → compress.minNudgeContextPercent + * + * - Deeper levels only override when the field is explicitly set; unset + * fields never clear shallower values. + * - 0 is an explicit "disable the floor" value, not "unset". + * - Unknown provider/model ids fall back up the cascade. + * - Returns undefined only when the field is unset everywhere (callers apply + * DEFAULT_MIN_NUDGE_CONTEXT_PERCENT). + */ +/** + * Resolve the full per-model / per-provider override set for the active + * provider/model, merged field-by-field: model-level fields win over + * provider-level fields. Unset fields are simply absent from the result — + * they never clear the global value. `0` / `false`-style explicit values are + * honored as set (only `undefined` means "unset"). Unknown provider/model + * ids resolve to an empty override set (global values apply). + * + * The `models` sub-map of a provider entry is structural and never leaks + * into the merged result. + */ +export function resolveCompressOverrides( + config: PluginConfig, + providerId?: string, + modelId?: string, +): CompressModelOverrides { + if (providerId === undefined) { + return {} + } + const providerEntry = config.compress?.providers?.[providerId] + if (!providerEntry) { + return {} + } + const { models: _models, ...providerFields } = providerEntry + const modelEntry = modelId !== undefined ? providerEntry.models?.[modelId] : undefined + if (modelEntry) { + return { ...providerFields, ...modelEntry } + } + return providerFields +} + +export function resolveMinNudgeContextPercent( + config: PluginConfig, + providerId?: string, + modelId?: string +): number | undefined { + const overrides = resolveCompressOverrides(config, providerId, modelId) + if (overrides.minNudgeContextPercent !== undefined) { + return overrides.minNudgeContextPercent + } + return config.compress?.minNudgeContextPercent +} + +/** + * Convert the resolved floor percent to absolute tokens against the active + * model's context window. The percent is clamped to 0–100 (mirroring + * resolveContextTokenLimit) and rounded. Returns undefined when the model + * context limit is unknown — whichever level set the percent, the floor then + * stays open (growth-only behavior, #343). + */ +export function resolveMinNudgeFloorTokens( + config: PluginConfig, + modelContextLimit: number | undefined, + providerId?: string, + modelId?: string +): number | undefined { + if (modelContextLimit === undefined) { + return undefined + } + const percent = + resolveMinNudgeContextPercent(config, providerId, modelId) ?? + DEFAULT_MIN_NUDGE_CONTEXT_PERCENT + const clamped = Math.min(100, Math.max(0, percent)) + return Math.round((clamped / 100) * modelContextLimit) +} + +/** + * Fields that applyCompressOverrides must NOT blanket-apply onto the effective + * config because their resolution needs custom precedence rules: + * - `maxContextLimit`: nested > legacy flat `modelMaxLimits` > global — enforced + * inside resolveContextTokenLimit, so blanket-applying it here would let the + * flat map override the nested value (wrong precedence). + */ +const OVERRIDE_BLANKET_APPLY_EXCLUDE = new Set< keyof CompressModelOverrides >(["maxContextLimit"]) + +/** + * Build the effective PluginConfig for the active provider/model: a shallow + * clone whose `compress` section has every nested override (model > provider) + * applied field-by-field on top of the global values. Callers pass the result + * down instead of the raw config, so every `config.compress.X` read in the + * pipeline picks up the cascade automatically. No override set → returns the + * input unchanged (identity, zero allocation). + */ +export function applyCompressOverrides( + config: PluginConfig, + providerId?: string, + modelId?: string, +): PluginConfig { + const overrides = resolveCompressOverrides(config, providerId, modelId) + const applied = Object.keys(overrides).filter( + (key) => !OVERRIDE_BLANKET_APPLY_EXCLUDE.has(key as keyof CompressModelOverrides), + ) + if (applied.length === 0 || !config.compress) { + return config + } + const effectiveCompress: Record = { ...config.compress } + for (const key of applied) { + effectiveCompress[key] = overrides[key as keyof CompressModelOverrides] + } + return { ...config, compress: effectiveCompress as unknown as CompressConfig } +} + export function addAnchor( anchorMessageIds: Set, anchorMessageId: string, diff --git a/tests/config-providers.test.ts b/tests/config-providers.test.ts new file mode 100644 index 00000000..9c961d7a --- /dev/null +++ b/tests/config-providers.test.ts @@ -0,0 +1,400 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { deepCloneConfig, mergeCompress, type CompressConfig } from "../lib/config" +import { getInvalidConfigKeys, validateConfigTypes } from "../lib/config-validation" +import { + applyCompressOverrides, + resolveCompressOverrides, + resolveContextTokenLimit, +} from "../lib/messages/inject/utils" + +const base: CompressConfig = { + permission: "allow", + showCompression: true, + summaryBuffer: true, + maxContextLimit: "55%", + minContextLimit: "45%", + nudgeFrequency: 5, + minNudgeContextPercent: 15, + nudgeForce: "soft", + protectedTools: [], + protectTags: false, + protectUserMessages: false, + maxSummaryLengthHard: 20000, + minCompressRange: 2000, + emergencyThresholdPercent: "98%", +} + +// ── mergeCompress: nested providers deep-merge across config layers ── + +test("providers: override layer wins when base has no providers", () => { + const merged = mergeCompress(base, { + providers: { anthropic: { models: { "claude-sonnet-4-6": { minNudgeContextPercent: 10 } } } }, + }) + assert.deepEqual(merged.providers, { + anthropic: { models: { "claude-sonnet-4-6": { minNudgeContextPercent: 10 } } }, + }) +}) + +test("providers: base layer preserved when override has no providers", () => { + const baseWithProviders = { + ...base, + providers: { openai: { minNudgeContextPercent: 8 } }, + } + const merged = mergeCompress(baseWithProviders, {}) + assert.deepEqual(merged.providers, { openai: { minNudgeContextPercent: 8 } }) +}) + +test("providers: per-provider and per-model deep merge — project layer adds without wiping", () => { + // Simulates the three-layer flow: global layer first, project layer second. + const afterGlobal = mergeCompress(base, { + providers: { + anthropic: { + minNudgeContextPercent: 8, + models: { "claude-sonnet-4-6": { minNudgeContextPercent: 10 } }, + }, + }, + }) + const afterProject = mergeCompress(afterGlobal, { + providers: { + openai: { minNudgeContextPercent: 6 }, // NEW provider — must not wipe anthropic + anthropic: { + minNudgeContextPercent: 9, // narrow the provider-level floor + models: { + "claude-haiku-4-5": { minNudgeContextPercent: 4 }, // NEW model + }, + }, + }, + }) + assert.deepEqual(afterProject.providers, { + anthropic: { + minNudgeContextPercent: 9, + models: { + "claude-sonnet-4-6": { minNudgeContextPercent: 10 }, // inherited + "claude-haiku-4-5": { minNudgeContextPercent: 4 }, // added + }, + }, + openai: { minNudgeContextPercent: 6 }, + }) +}) + +test("providers: a higher layer cannot clear a provider/model it leaves unset", () => { + const baseWithProviders = { + ...base, + providers: { + anthropic: { + minNudgeContextPercent: 8, + models: { "claude-sonnet-4-6": { minNudgeContextPercent: 10 } }, + }, + }, + } + const merged = mergeCompress(baseWithProviders, { + providers: { anthropic: { models: {} } }, + }) + // models: {} carries no explicit model → base models survive; the provider + // level was not set in the override either → 8 survives. + assert.deepEqual(merged.providers, { + anthropic: { + minNudgeContextPercent: 8, + models: { "claude-sonnet-4-6": { minNudgeContextPercent: 10 } }, + }, + }) +}) + +test("providers: deepCloneConfig isolates the nested maps", () => { + const config = { + enabled: true, + autoUpdate: true, + debug: false, + pruneNotification: "off", + pruneNotificationType: "chat", + commands: { enabled: true, protectedTools: [] }, + experimental: { allowSubAgents: false, customPrompts: false }, + protectedFilePatterns: [], + compress: { + ...base, + providers: { + anthropic: { + minNudgeContextPercent: 8, + models: { "claude-sonnet-4-6": { minNudgeContextPercent: 10 } }, + }, + }, + }, + gc: { + algorithm: "truncate", + promotionThreshold: 5, + maxBlockAge: 15, + maxOldGenSummaryLength: 3000, + majorGcThresholdPercent: "100%", + batchCleanup: { lowThreshold: "60%", highThreshold: "75%", forceThreshold: "90%" }, + }, + qualityGate: { + enabled: false, + algorithm: "rouge-recall-v1", + algorithms: {}, + }, + messageFilters: { + enabled: false, + filters: {}, + }, + } as any + const clone = deepCloneConfig(config) + // Mutate every nested level of the clone. + clone.compress.providers!.anthropic.minNudgeContextPercent = 99 + clone.compress.providers!.anthropic.models!["claude-sonnet-4-6"]!.minNudgeContextPercent = 99 + clone.compress.providers!.openai = { minNudgeContextPercent: 99 } + // The original is untouched. + assert.equal(config.compress.providers.anthropic.minNudgeContextPercent, 8) + assert.equal(config.compress.providers.anthropic.models["claude-sonnet-4-6"].minNudgeContextPercent, 10) + assert.equal(config.compress.providers.openai, undefined) +}) + +// ── validation: structure + typo safety ── + +test("providers: valid nested config passes validation and key walking", () => { + const userConfig = { + compress: { + providers: { + anthropic: { + minNudgeContextPercent: 8, + models: { "claude-sonnet-4-6": { minNudgeContextPercent: 10 } }, + }, + }, + }, + } + assert.deepEqual(getInvalidConfigKeys(userConfig), []) + const errors = validateConfigTypes(userConfig) + assert.deepEqual(errors, []) +}) + +test("providers: rejects non-object providers / provider / models entries", () => { + assert.deepEqual(validateConfigTypes({ compress: { providers: "nope" } }).map((e) => e.key), [ + "compress.providers", + ]) + assert.deepEqual( + validateConfigTypes({ compress: { providers: { anthropic: 5 } } }).map((e) => e.key), + ["compress.providers.anthropic"] + ) + assert.deepEqual( + validateConfigTypes({ compress: { providers: { anthropic: { models: 5 } } } }).map((e) => e.key), + ["compress.providers.anthropic.models"] + ) + assert.deepEqual( + validateConfigTypes({ + compress: { providers: { anthropic: { models: { "claude-x": 5 } } } }, + }).map((e) => e.key), + ["compress.providers.anthropic.models.claude-x"] + ) +}) + +test("providers: rejects out-of-range / non-number percent values", () => { + const keys = (providers: unknown) => + validateConfigTypes({ compress: { providers } }).map((e) => e.key) + assert.deepEqual(keys({ anthropic: { minNudgeContextPercent: -1 } }), [ + "compress.providers.anthropic.minNudgeContextPercent", + ]) + assert.deepEqual(keys({ anthropic: { minNudgeContextPercent: 101 } }), [ + "compress.providers.anthropic.minNudgeContextPercent", + ]) + assert.deepEqual(keys({ anthropic: { minNudgeContextPercent: "30%" } }), [ + "compress.providers.anthropic.minNudgeContextPercent", + ]) + assert.deepEqual(keys({ anthropic: { models: { m: { minNudgeContextPercent: 150 } } } }), [ + "compress.providers.anthropic.models.m.minNudgeContextPercent", + ]) + // Boundary values are valid: 0 disables the floor, 100 clamps at the window. + assert.deepEqual(keys({ anthropic: { models: { m: { minNudgeContextPercent: 0 } } } }), []) + assert.deepEqual(keys({ anthropic: { models: { m: { minNudgeContextPercent: 100 } } } }), []) +}) + +test("providers: unknown fields are rejected (typo safety)", () => { + assert.deepEqual( + validateConfigTypes({ + compress: { providers: { anthropic: { minNudgeContextPecent: 10 } } }, + }).map((e) => e.key), + ["compress.providers.anthropic.minNudgeContextPecent"] + ) + assert.deepEqual( + validateConfigTypes({ + compress: { providers: { anthropic: { models: { m: { floor: 10 } } } } }, + }).map((e) => e.key), + ["compress.providers.anthropic.models.m.floor"] + ) +}) + +// ── all-field cascade: resolveCompressOverrides / applyCompressOverrides ── + +const pluginConfig = (compress: CompressConfig) => + ({ compress }) as Parameters[0] + +test("all-field cascade: provider fields apply to every field, model fields win per field", () => { + const config = pluginConfig({ + ...base, + providers: { + anthropic: { + nudgeFrequency: 3, + minNudgeContextPercent: 8, + nudgeForce: "strong", + models: { + "claude-sonnet-4-6": { nudgeFrequency: 1, summaryBuffer: false }, + }, + }, + }, + }) + // Model level: model wins on nudgeFrequency, inherits provider on the rest. + assert.deepEqual(resolveCompressOverrides(config, "anthropic", "claude-sonnet-4-6"), { + nudgeFrequency: 1, + minNudgeContextPercent: 8, + nudgeForce: "strong", + summaryBuffer: false, + }) + // Sibling model: provider fields only. + assert.deepEqual(resolveCompressOverrides(config, "anthropic", "claude-haiku-4-5"), { + nudgeFrequency: 3, + minNudgeContextPercent: 8, + nudgeForce: "strong", + }) + // Unknown provider / no provider id: no overrides at all. + assert.deepEqual(resolveCompressOverrides(config, "openai", "gpt-5"), {}) + assert.deepEqual(resolveCompressOverrides(config, undefined, "claude-sonnet-4-6"), {}) +}) + +test("all-field cascade: applyCompressOverrides is identity when nothing applies", () => { + const config = pluginConfig({ ...base, providers: { anthropic: { nudgeFrequency: 3 } } }) + // Unknown provider → same reference, zero allocation. + assert.equal(applyCompressOverrides(config, "openai", "gpt-5"), config) + assert.equal(applyCompressOverrides(config, undefined), config) +}) + +test("all-field cascade: applyCompressOverrides swaps fields but never maxContextLimit", () => { + const config = pluginConfig({ + ...base, + maxContextLimit: "55%", + nudgeFrequency: 5, + providers: { + anthropic: { + maxContextLimit: "30%", // must NOT be blanket-applied (explicit path below) + nudgeFrequency: 2, + protectedTools: ["skill", "task"], + models: { "claude-sonnet-4-6": { nudgeGrowthTokens: 10000 } }, + }, + }, + }) + const applied = applyCompressOverrides(config, "anthropic", "claude-sonnet-4-6") + assert.notEqual(applied, config) + assert.equal(applied.compress.nudgeFrequency, 2) + assert.deepEqual(applied.compress.protectedTools, ["skill", "task"]) + assert.equal(applied.compress.nudgeGrowthTokens, 10000) + // maxContextLimit is excluded from the blanket swap — it flows through + // resolveContextTokenLimit's explicit precedence chain instead. + assert.equal(applied.compress.maxContextLimit, "55%") + // The input config is never mutated. + assert.equal(config.compress.nudgeFrequency, 5) + assert.equal(config.compress.nudgeGrowthTokens, undefined) +}) + +test("all-field cascade: nested maxContextLimit beats the flat modelMaxLimits map", () => { + const config = pluginConfig({ + ...base, + maxContextLimit: "55%", + modelMaxLimits: { + "anthropic/claude-sonnet-4-6": 400000, + "anthropic/claude-haiku-4-5": 300000, + }, + providers: { + anthropic: { + models: { "claude-sonnet-4-6": { maxContextLimit: 250000 } }, + }, + }, + }) + const state = { modelContextLimit: 1000000 } as Parameters[1] + assert.equal( + resolveContextTokenLimit(config, state, "anthropic", "claude-sonnet-4-6", "max"), + 250000 + ) + // A sibling model still uses the flat map; an unknown model uses the global. + assert.equal(resolveContextTokenLimit(config, state, "anthropic", "claude-haiku-4-5", "max"), 300000) + assert.equal(resolveContextTokenLimit(config, state, "openai", "gpt-5", "max"), 550000) +}) + +// ── validation: multi-field override values ── + +test("providers: multi-field validation accepts every overridable field type", () => { + const userConfig = { + compress: { + providers: { + anthropic: { + showCompression: true, + summaryBuffer: false, + protectTags: true, + protectUserMessages: false, + lastSegmentSoftBlock: false, + preserveLastUserMessage: false, + maxContextLimit: "60%", + emergencyThresholdPercent: "95%", + minNudgeContextPercent: 7, + nudgeFrequency: 3, + iterationNudgeThreshold: 10, + toolOutputNudgeThreshold: 8000, + nudgeGrowthTokens: 40000, + minNudgeGrowthRatio: 0.5, + minNudgeGrowthFloor: 6000, + nudgeForce: "strong", + protectedTools: ["skill", "mcp_*"], + maxSummaryLengthHard: 15000, + minCompressRange: 1000, + maxVisibleSegments: 40, + keepEmbedMaxChars: 3000, + preserveRecentMessages: 30, + preserveRecentTokens: 25000, + models: { + "claude-sonnet-4-6": { nudgeFrequency: 2, nudgeForce: "soft" }, + }, + }, + }, + }, + } + assert.deepEqual(getInvalidConfigKeys(userConfig), []) + assert.deepEqual(validateConfigTypes(userConfig), []) +}) + +test("providers: multi-field validation rejects wrong-typed values at both levels", () => { + const keys = (providers: unknown) => + validateConfigTypes({ compress: { providers } }).map((e) => e.key) + // Provider level. + assert.deepEqual(keys({ anthropic: { nudgeForce: "hard" } }), [ + "compress.providers.anthropic.nudgeForce", + ]) + assert.deepEqual(keys({ anthropic: { protectedTools: "skill" } }), [ + "compress.providers.anthropic.protectedTools", + ]) + assert.deepEqual(keys({ anthropic: { protectedTools: ["ok", 5] } }), [ + "compress.providers.anthropic.protectedTools", + ]) + assert.deepEqual(keys({ anthropic: { nudgeFrequency: 0 } }), [ + "compress.providers.anthropic.nudgeFrequency", + ]) + assert.deepEqual(keys({ anthropic: { nudgeGrowthTokens: -1 } }), [ + "compress.providers.anthropic.nudgeGrowthTokens", + ]) + assert.deepEqual(keys({ anthropic: { showCompression: "yes" } }), [ + "compress.providers.anthropic.showCompression", + ]) + assert.deepEqual(keys({ anthropic: { maxContextLimit: "abc" } }), [ + "compress.providers.anthropic.maxContextLimit", + ]) + // Model level. + assert.deepEqual(keys({ anthropic: { models: { m: { nudgeForce: "HARD" } } } }), [ + "compress.providers.anthropic.models.m.nudgeForce", + ]) + assert.deepEqual(keys({ anthropic: { models: { m: { summaryBuffer: 1 } } } }), [ + "compress.providers.anthropic.models.m.summaryBuffer", + ]) + // Structural fields stay non-overridable. + assert.deepEqual(keys({ anthropic: { permission: "ask" } }), [ + "compress.providers.anthropic.permission", + ]) + assert.deepEqual(keys({ anthropic: { models: { m: { providers: {} } } } }), [ + "compress.providers.anthropic.models.m.providers", + ]) +}) diff --git a/tests/inject.test.ts b/tests/inject.test.ts index 0af2920d..46aae64b 100644 --- a/tests/inject.test.ts +++ b/tests/inject.test.ts @@ -9,7 +9,7 @@ import type { PluginConfig } from "../lib/config" import { Logger } from "../lib/logger" import { injectMessageIds, injectCompressNudges } from "../lib/messages/inject/inject" import { cacheSystemPromptTokens } from "../lib/ui/utils" -import { estimateContextComposition } from "../lib/messages/inject/utils" +import { estimateContextComposition, resolveMinNudgeContextPercent, resolveMinNudgeFloorTokens } from "../lib/messages/inject/utils" import { countTokens } from "../lib/token-utils" import { createSyntheticUserMessage } from "../lib/messages/utils" import { createSessionState, ensureSessionInitialized, type WithParts } from "../lib/state" @@ -347,6 +347,9 @@ test("injectCompressNudges: post-compress baseline then large growth DOES nudge" state.modelContextLimit = 1_000_000 const config = buildConfig() config.compress.maxContextLimit = 800_000 + // Growth floor is minNudgeContextPercent (15%, pinned by the buildConfig factory = 150K on a 1M model), + // below the turn-2 context (305K), so the floor is open and this test + // isolates the growth mechanism. minContextLimit no longer gates growth nudges. config.compress.minContextLimit = 550_000 // Turn 1: compress → baseline set to 250K @@ -379,6 +382,9 @@ test("nudge threshold halves after first nudge without compress (issue #23)", () state.nudges.lastPerMessageNudgeTokens = 100_000 const config = buildConfig() config.compress.maxContextLimit = 800_000 + // Growth floor is minNudgeContextPercent (15%, pinned by the buildConfig factory = 150K on a 1M model), + // at/below the turn contexts (150K/165K/175K), so the floor is open and this + // test isolates the threshold-halving mechanism. config.compress.minContextLimit = 200_000 const messages1: WithParts[] = [ @@ -1412,14 +1418,14 @@ test("stale contextLimitAnchors: contextLimitNudge NOT injected when context bel const messages: WithParts[] = [ userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "done", { input: 90_000, output: 10_000 }, [ + assistantMsgWithTokens("a1", "done", { input: 140_000, output: 10_000 }, [ toolPart("c1", "x".repeat(620_000)), ]), userMsg("u2", "next"), ] injectCompressNudges(state, config, logger, messages, makePrompts()) - assert.equal(state.nudges.shouldInjectThisTurn, true, "nudge fires (50K growth >= 22500 floor)") + assert.equal(state.nudges.shouldInjectThisTurn, true, "nudge fires (100K growth >= 22500 growthFloor, 150K >= 15% floor)") assert.equal(state.nudges.contextLimitAnchors.size, 0, "stale contextLimitAnchors cleared") const injected = suffixText(messages) @@ -1868,3 +1874,729 @@ test("Issue #255: stable system prompt cache survives compression in multi-turn assert.ok(comp4.systemTokens < 200_000, "turn 4: system estimate must not inflate to later assistant input") }) +// ── Issue #342: T1 growth nudges must respect the minNudgeContextPercent floor ── +// computeShouldNudge() only uses overMinLimit/overMaxLimit to pick the tips +// variant, so the growth floor is enforced in inject.ts (nudgeAllowed): +// minNudgeContextPercent × model context. These tests lock that floor: growth +// nudges are suppressed below it and fire once context crosses it, while +// over-max / emergency paths and T2/T3 tier promotion remain independent. + +test("issue #342: growth nudge suppressed below the minNudgeContextPercent floor, fires once context crosses it", () => { + const state = createSessionState() + state.modelContextLimit = 1_000_000 + state.nudges.lastPerMessageNudgeTokens = 100_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + state.messageIds.byRawId.set("u2", "m00003") + state.messageIds.byRawId.set("a2", "m00004") + + const config = buildConfig() + config.compress.maxContextLimit = 800_000 + config.compress.minNudgeContextPercent = 30 // floor at 30% of 1M = 300K + + // Turn 1: currentTokens = 200K (180K+20K). Growth = 200K-100K = 100K >= 50K threshold + // AND >= 22.5K growthFloor, but 200K < 300K floor → the floor SUPPRESSES the growth nudge. + const turn1: WithParts[] = [ + userMsg("u1", "hello"), + assistantMsgWithTokens("a1", "done", { input: 180_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn1, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, false, "200K < 300K floor → growth nudge suppressed despite 100K growth") + assert.equal(state.nudges.lastNudgeShownTokens, undefined, "no nudge shown below floor") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 100_000, "baseline preserved below floor (not advanced)") + + // Turn 2: currentTokens = 320K (300K+20K). Growth = 320K-100K = 220K >= 50K. + // 320K >= 300K floor → floor OPEN → nudge FIRES. + const turn2: WithParts[] = [ + userMsg("u1", "hello"), + assistantMsgWithTokens("a1", "done", { input: 180_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + userMsg("u2", "next"), + assistantMsgWithTokens("a2", "more", { input: 300_000, output: 20_000 }, [ + toolPart("c2", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn2, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "320K >= 300K floor + 220K growth → nudge fires") + assert.equal(state.nudges.lastNudgeShownTokens, 320_000, "lastNudgeShownTokens set to currentTokens") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 100_000, "baseline NOT updated after nudge — only compress resets") +}) + +test("issue #342: full growth cycle baseline → nudge → compress → new baseline → nudge (min-gate open)", () => { + const state = createSessionState() + state.modelContextLimit = 1_000_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + state.messageIds.byRawId.set("u2", "m00003") + state.messageIds.byRawId.set("a2", "m00004") + state.messageIds.byRawId.set("u3", "m00005") + state.messageIds.byRawId.set("a3", "m00006") + + const config = buildConfig() + config.compress.maxContextLimit = 800_000 + config.compress.minNudgeContextPercent = 30 // floor at 30% of 1M = 300K + + // Turn 1: first transform → baseline established at 150K. No nudge. + const turn1: WithParts[] = [ + userMsg("u1", "hello"), + assistantMsgWithTokens("a1", "work", { input: 130_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn1, {} as any) + assert.equal(state.nudges.lastPerMessageNudgeTokens, 150_000, "turn 1: baseline established at 150K") + assert.equal(state.nudges.shouldInjectThisTurn, false, "turn 1: baseline establishment only") + + // Turn 2: currentTokens = 350K (>= 300K floor), growth = 350K-150K = 200K >= 50K → nudge fires. + const turn2: WithParts[] = [ + userMsg("u1", "hello"), + assistantMsgWithTokens("a1", "work", { input: 130_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + userMsg("u2", "next"), + assistantMsgWithTokens("a2", "more", { input: 330_000, output: 20_000 }, [ + toolPart("c2", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn2, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "turn 2: 350K >= 300K floor + 200K growth → nudge fires") + assert.equal(state.nudges.lastNudgeShownTokens, 350_000, "turn 2: nudge baseline set to currentTokens") + + // Turn 3: model compresses → baseline reset to post-compress 200K. + const turn3: WithParts[] = [ + userMsg("u1", "hello"), + assistantMsgWithTokens("a1", "work", { input: 130_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + userMsg("u2", "next"), + assistantMsgWithTokens("a2", "compressing", { input: 180_000, output: 20_000 }, [ + compressToolPart("c2", "compressed"), + ]), + ] + injectCompressNudges(state, config, logger, turn3, {} as any) + assert.equal(state.nudges.lastPerMessageNudgeTokens, 200_000, "turn 3: compress resets baseline to post-compress 200K") + assert.equal(state.nudges.shouldInjectThisTurn, false, "turn 3: compress turn, no nudge") + + // Turn 4: currentTokens = 400K (>= 300K floor), growth = 400K-200K = 200K >= 50K → nudge fires again. + const turn4: WithParts[] = [ + userMsg("u3", "more"), + assistantMsgWithTokens("a3", "result", { input: 380_000, output: 20_000 }, [ + toolPart("c3", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn4, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "turn 4: 400K >= 300K floor + 200K growth from new baseline → nudge fires again") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 200_000, "turn 4: baseline NOT updated after nudge") +}) + +test("issue #342: growth floor holds in production config (preserveRecentMessages > 0)", () => { + const state = createSessionState() + state.modelContextLimit = 1_000_000 + state.nudges.lastPerMessageNudgeTokens = 100_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + state.messageIds.byRawId.set("u2", "m00003") + state.messageIds.byRawId.set("a2", "m00004") + + const config = buildConfig() + config.compress.preserveRecentMessages = 2 // production-like: last 2 messages protected + config.compress.maxContextLimit = 800_000 + config.compress.minNudgeContextPercent = 30 // floor at 30% of 1M = 300K + + // 4 messages; last 2 (u2, a2) in preserve-recent zone, a1's tool output compressible. + // currentTokens = 200K (a2: 180K+20K) < 300K floor → the floor suppresses (compressible + // content exists, so this is the floor, not nothingToCompress). + const messages: WithParts[] = [ + userMsg("u1", "hello"), + assistantMsgWithTokens("a1", "done", { input: 180_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + userMsg("u2", "next"), + assistantMsgWithTokens("a2", "more", { input: 180_000, output: 20_000 }), + ] + injectCompressNudges(state, config, logger, messages, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, false, "200K < 300K floor → floor suppresses in production config") + assert.equal(state.nudges.lastNudgeShownTokens, undefined, "no nudge shown below floor") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 100_000, "baseline preserved") +}) + +test("issue #342: over-max nudge bypasses the growth floor", () => { + // Defensive: even when the growth floor (minNudgeContextPercent) is set above + // the current context, an over-max context must still nudge — overMaxLimit + // bypasses the floor. + const state = createSessionState() + state.modelContextLimit = 1_000_000 + state.nudges.lastPerMessageNudgeTokens = 100_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + + const config = buildConfig() + config.compress.maxContextLimit = 500_000 + config.compress.minNudgeContextPercent = 80 // floor at 80% of 1M = 800K (above context) + + // currentTokens = 550K (500K+50K). overMaxLimit (550K > 500K) but below the + // 800K floor. Growth = 450K >= 50K. The floor would suppress, but overMaxLimit + // bypasses it. + const messages: WithParts[] = [ + userMsg("u1", "hello"), + assistantMsgWithTokens("a1", "done", { input: 500_000, output: 50_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, messages, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "550K > 500K max → nudge fires despite 550K < 800K floor (overMaxLimit bypass)") + assert.equal(state.nudges.lastNudgeShownTokens, 550_000, "nudge baseline set") +}) + +test("issue #342: growth nudge still fires when the model context limit is unknown (floor unresolvable)", () => { + // Regression lock: when the model does not report a context limit, the + // minNudgeContextPercent floor cannot be computed. The floor must NOT be + // treated as "below floor" — growth nudges keep their pre-#342 growth-only + // behavior instead of being suppressed for the whole session. + const state = createSessionState() + // state.modelContextLimit intentionally left undefined + state.nudges.lastPerMessageNudgeTokens = 100_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + + const config = buildConfig() + config.compress.maxContextLimit = "80%" // percent → unresolvable without a model limit + + // currentTokens = 200K (180K+20K). Growth = 200K-100K = 100K >= 50K threshold + // AND >= 22.5K growthFloor. Model limit unknown → floor unresolvable → nudge FIRES. + const messages: WithParts[] = [ + userMsg("u1", "hello"), + assistantMsgWithTokens("a1", "done", { input: 180_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, messages, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "unknown model limit → floor unresolvable → growth-only behavior preserved (nudge fires)") + assert.equal(state.nudges.lastNudgeShownTokens, 200_000, "nudge baseline set") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 100_000, "baseline NOT updated after nudge") +}) + +test("issue #342: T2 tier-promotion fires below the growth floor (independent of the floor)", () => { + // T2/T3 tier-promotion nudges have an independent cadence and never consult + // the growth floor — they must fire even while T1 is floor-suppressed. + const state = createSessionState() + state.sessionId = "test-t2-floor" + state.modelContextLimit = 1_000_000 + state.nudges.lastPerMessageNudgeTokens = 100_000 + + const config = buildConfig() + config.compress.maxContextLimit = 990_000 + config.compress.minNudgeContextPercent = 80 // floor at 80% of 1M = 800K (above the 200K context) + config.compress.nudgeGrowthTokens = 10_000 + config.compress.minNudgeGrowthFloor = 5_000 + config.compress.minNudgeGrowthRatio = 0.01 + + // Seed T1 blocks so tier1Tokens (25K) >= nudgeGrowthTokens (10K) + for (let i = 0; i < 5; i++) { + const blockId = i + 1 + state.prune.messages.blocksById.set(blockId, { + blockId, + runId: i + 1, + active: true, + tier: 1, + generation: "young", + survivedCount: 1, + directMessageIds: [], + effectiveMessageIds: [], + consumedBlockIds: [], + parentBlockIds: [], + summary: "T1 summary ".repeat(200), + summaryTokens: 5_000, + topic: `T1 block ${i}`, + createdAt: Date.now(), + }) + state.prune.messages.activeBlockIds.add(blockId) + } + + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + + // currentTokens = 200K (180K+20K). T1: growth = 100K >= 10K threshold, but + // 200K < 800K floor → the floor suppresses T1. T2: tier1Tokens 25K >= 10K, + // cadence met, 5 candidates >= 2 → T2 fires below the growth floor. + const messages: WithParts[] = [ + userMsg("u1", "hello"), + assistantMsgWithTokens("a1", "done", { input: 180_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, messages, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "T2 fires below the growth floor (independent cadence)") + assert.equal(state.nudges.lastTier2NudgeTokens, 200_000, "T2 fired (lastTier2NudgeTokens set) while T1 stayed floor-suppressed") +}) + +test("issue #342 follow-up: unset minNudgeContextPercent falls back to the low 5% default floor", () => { + const state = createSessionState() + state.modelContextLimit = 1_000_000 + state.nudges.lastPerMessageNudgeTokens = 50_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + + const config = buildConfig() + config.compress.maxContextLimit = 800_000 + // Simulate a config that never set the field: the code must fall back to + // the (deliberately low) 5% default, NOT the pre-fix 15%. + delete (config.compress as { minNudgeContextPercent?: number }).minNudgeContextPercent + + // currentTokens = 100K (80K+20K). Growth = 100K-50K = 50K >= 50K threshold + // AND >= 22.5K growthFloor. Fallback floor = 5% of 1M = 50K → 100K >= 50K + // → the nudge FIRES. (A 15% fallback would put the floor at 150K and + // SUPPRESS this — the default must stay low so typical working cycles on + // large-window models are not shifted; 15% binds on ≥400K windows.) + const messages: WithParts[] = [ + userMsg("u1", "hello"), + assistantMsgWithTokens("a1", "done", { input: 80_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, messages, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "100K >= 50K (5% default floor) with 50K growth → nudge fires") + assert.equal(state.nudges.lastNudgeShownTokens, 100_000, "nudge shown at current tokens") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 50_000, "baseline only advances on compress, not on nudge") +}) + + +// ── Issue #344: per-provider / per-model growth-nudge floor (nested providers.models) ── +// compress.providers.{provider}.{field} and .models.{model}.{field} cascade +// field-by-field: model > provider > global (billion-context-pi style). +// These tests lock the resolution order, the 0-disables escape hatch, the +// fallback for unknown provider/model ids, and the multi-turn gate behavior. + +function userMsgWithModel(id: string, text: string, providerId: string, modelId: string): WithParts { + return { + info: { + id, + role: "user", + sessionID: SID, + agent: "a", + time: { created: 1 }, + model: { providerID: providerId, modelID: modelId }, + } as WithParts["info"], + parts: [textPart(id, text)], + } +} + +test("issue #344 resolver: minNudgeContextPercent cascades model > provider > global", () => { + const config = buildConfig() + config.compress.minNudgeContextPercent = 5 + config.compress.providers = { + anthropic: { + minNudgeContextPercent: 8, + models: { + "claude-sonnet-4-6": { minNudgeContextPercent: 10 }, + }, + }, + openai: { + models: { "gpt-5": { minNudgeContextPercent: 20 } }, + }, + } + // Model level wins over provider and global. + assert.equal(resolveMinNudgeContextPercent(config, "anthropic", "claude-sonnet-4-6"), 10) + // Provider level applies to its other models (no model entry). + assert.equal(resolveMinNudgeContextPercent(config, "anthropic", "claude-haiku-4-5"), 8) + // Model entry without provider-level field still resolves (model > global). + assert.equal(resolveMinNudgeContextPercent(config, "openai", "gpt-5"), 20) + // Unknown model in a provider without provider-level field falls back to global. + assert.equal(resolveMinNudgeContextPercent(config, "openai", "gpt-4o"), 5) + // Unknown provider falls back to global. + assert.equal(resolveMinNudgeContextPercent(config, "zhipu", "glm-5"), 5) + // No model info at all falls back to global. + assert.equal(resolveMinNudgeContextPercent(config), 5) +}) + +test("issue #344 resolver: 0 at a deeper level is an explicit disable, not unset", () => { + const config = buildConfig() + config.compress.minNudgeContextPercent = 15 + config.compress.providers = { + anthropic: { models: { "claude-sonnet-4-6": { minNudgeContextPercent: 0 } } }, + } + assert.equal(resolveMinNudgeContextPercent(config, "anthropic", "claude-sonnet-4-6"), 0) + // Floor tokens clamp to 0 → every currentTokens >= 0 → gate always open. + assert.equal(resolveMinNudgeFloorTokens(config, 1_000_000, "anthropic", "claude-sonnet-4-6"), 0) + // Sibling model without the override keeps the global 15%. + assert.equal(resolveMinNudgeContextPercent(config, "anthropic", "claude-haiku-4-5"), 15) +}) + +test("issue #344 resolver: unset everywhere returns undefined; floor fn applies the 5% default", () => { + const config = buildConfig() + delete (config.compress as { minNudgeContextPercent?: number }).minNudgeContextPercent + config.compress.providers = { anthropic: { models: { "claude-sonnet-4-6": {} } } } + assert.equal(resolveMinNudgeContextPercent(config, "anthropic", "claude-sonnet-4-6"), undefined) + // resolveMinNudgeFloorTokens applies the deliberately-low default (5%). + assert.equal(resolveMinNudgeFloorTokens(config, 1_000_000, "anthropic", "claude-sonnet-4-6"), 50_000) +}) + +test("issue #344 resolver: percent clamps to 0-100 and rounds against the window", () => { + const config = buildConfig() + config.compress.providers = { + anthropic: { models: { "claude-sonnet-4-6": { minNudgeContextPercent: 150 } } }, + } + assert.equal(resolveMinNudgeFloorTokens(config, 1_000_000, "anthropic", "claude-sonnet-4-6"), 1_000_000) + // Unknown window → floor unresolvable → undefined (gate stays open). + assert.equal(resolveMinNudgeFloorTokens(config, undefined, "anthropic", "claude-sonnet-4-6"), undefined) +}) + +test("issue #344: model-level floor suppresses the growth nudge, then fires once crossed (multi-turn)", () => { + const state = createSessionState() + state.modelContextLimit = 1_000_000 + state.nudges.lastPerMessageNudgeTokens = 100_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + state.messageIds.byRawId.set("u2", "m00003") + state.messageIds.byRawId.set("a2", "m00004") + + const config = buildConfig() + config.compress.maxContextLimit = 800_000 + config.compress.minNudgeContextPercent = 5 // global floor would be 50K + config.compress.providers = { + anthropic: { + models: { + // Model-level override raises the floor for THIS model only: + // 30% of 1M = 300K. + "claude-sonnet-4-6": { minNudgeContextPercent: 30 }, + }, + }, + } + + // Turn 1: currentTokens = 200K (180K+20K). Growth = 100K >= 50K threshold + // and >= 22.5K growthFloor, but 200K < 300K model-level floor → suppressed. + const turn1: WithParts[] = [ + userMsgWithModel("u1", "hello", "anthropic", "claude-sonnet-4-6"), + assistantMsgWithTokens("a1", "done", { input: 180_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn1, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, false, "200K < 300K model-level floor → suppressed despite 100K growth") + assert.equal(state.nudges.lastNudgeShownTokens, undefined, "no nudge shown below the model-level floor") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 100_000, "baseline preserved below floor") + + // Turn 2: currentTokens = 320K. Growth = 220K >= 50K. 320K >= 300K → fires. + const turn2: WithParts[] = [ + userMsgWithModel("u1", "hello", "anthropic", "claude-sonnet-4-6"), + assistantMsgWithTokens("a1", "done", { input: 180_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + userMsgWithModel("u2", "next", "anthropic", "claude-sonnet-4-6"), + assistantMsgWithTokens("a2", "more", { input: 300_000, output: 20_000 }, [ + toolPart("c2", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn2, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "320K >= 300K model-level floor + 220K growth → fires") + assert.equal(state.nudges.lastNudgeShownTokens, 320_000, "lastNudgeShownTokens set to currentTokens") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 100_000, "baseline NOT advanced by the nudge") +}) + +test("issue #344: provider-level floor applies to every model of that provider", () => { + const state = createSessionState() + state.modelContextLimit = 1_000_000 + state.nudges.lastPerMessageNudgeTokens = 100_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + + const config = buildConfig() + config.compress.maxContextLimit = 800_000 + config.compress.minNudgeContextPercent = 5 + config.compress.providers = { + anthropic: { minNudgeContextPercent: 25 }, // no models entry + } + + // Active model has no model-level entry → provider floor 25% of 1M = 250K. + // currentTokens = 200K with 100K growth → below 250K → suppressed. + const messages: WithParts[] = [ + userMsgWithModel("u1", "hello", "anthropic", "claude-haiku-4-5"), + assistantMsgWithTokens("a1", "done", { input: 180_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, messages, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, false, "200K < 250K provider-level floor → suppressed") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 100_000, "baseline preserved") +}) + +test("issue #344: unknown provider falls back to the global floor", () => { + const state = createSessionState() + state.modelContextLimit = 1_000_000 + state.nudges.lastPerMessageNudgeTokens = 100_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + + const config = buildConfig() + config.compress.maxContextLimit = 800_000 + config.compress.minNudgeContextPercent = 5 // global floor 50K + config.compress.providers = { + anthropic: { minNudgeContextPercent: 25 }, // not the active provider + } + + // Active provider zhipu is unknown → global 5% floor (50K). 200K >= 50K + // with 100K growth → the nudge FIRES (no per-provider suppression). + const messages: WithParts[] = [ + userMsgWithModel("u1", "hello", "zhipu", "glm-5"), + assistantMsgWithTokens("a1", "done", { input: 180_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, messages, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "unknown provider → global 5% floor → fires") + assert.equal(state.nudges.lastNudgeShownTokens, 200_000, "nudge shown at current tokens") +}) + +test("issue #344: model-level 0 disables the floor for that model while siblings keep the global", () => { + const state = createSessionState() + state.modelContextLimit = 1_000_000 + state.nudges.lastPerMessageNudgeTokens = 100_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + + const config = buildConfig() + config.compress.maxContextLimit = 800_000 + config.compress.minNudgeContextPercent = 15 // global floor = 150K + config.compress.providers = { + anthropic: { models: { "claude-sonnet-4-6": { minNudgeContextPercent: 0 } } }, + } + + // currentTokens = 115K (95K+20K), growth = 115K-60K = 55K >= 50K threshold, + // but 115K < 150K global floor — the model-level 0 kills the floor for + // THIS model → the nudge fires where the global config would suppress it. + state.nudges.lastPerMessageNudgeTokens = 60_000 + const messages: WithParts[] = [ + userMsgWithModel("u1", "hello", "anthropic", "claude-sonnet-4-6"), + assistantMsgWithTokens("a1", "done", { input: 95_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, messages, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "model-level 0 disables the floor → 55K growth fires at 115K") + assert.equal(state.nudges.lastNudgeShownTokens, 115_000, "nudge shown at current tokens") +}) + +test("issue #344 all-field cascade: model-level maxContextLimit lowers the over-max band (multi-turn)", () => { + const state = createSessionState() + state.modelContextLimit = 1_000_000 + // Baseline 220K: growth = 250K − 220K = 30K — above the 22.5K anti-thrash + // growthFloor but below the 50K default threshold, so the ONLY way turn 1 + // can nudge is the over-max path via the model-level band (overMaxLimit + // makes decision.shouldNudge true; the growth path alone could not). + state.nudges.lastPerMessageNudgeTokens = 220_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + state.messageIds.byRawId.set("u2", "m00003") + state.messageIds.byRawId.set("a2", "m00004") + + const config = buildConfig() + config.compress.maxContextLimit = 800_000 // global band start + config.compress.providers = { + anthropic: { + models: { + // Model-level override narrows the band for THIS model only: + // 20% of 1M = 200K. + "claude-sonnet-4-6": { maxContextLimit: "20%" }, + }, + }, + } + + // Turn 1: currentTokens = 250K — below the global 800K band, but above the + // model-level 200K band → over-max nudge fires (overMaxLimit also bypasses + // the growth floor and growth threshold). + const turn1: WithParts[] = [ + userMsgWithModel("u1", "hello", "anthropic", "claude-sonnet-4-6"), + assistantMsgWithTokens("a1", "done", { input: 230_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn1, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "250K >= 200K model-level max → over-max nudge fires") + assert.equal(state.nudges.lastNudgeShownTokens, 250_000, "nudge baseline set to currentTokens") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 220_000, "per-message baseline untouched by over-max nudge") + state.nudges.shouldInjectThisTurn = false + + // Turn 2: same context size, different provider/model — the override must + // NOT apply → 250K < 800K global → no over-max nudge. + const turn2: WithParts[] = [ + userMsgWithModel("u1", "hello", "openai", "gpt-5"), + assistantMsgWithTokens("a1", "done", { input: 230_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn2, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, false, "unknown provider keeps the global 800K band → 250K not over max") + assert.equal(state.nudges.lastNudgeShownTokens, 250_000, "baseline from turn 1 preserved (no new nudge)") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 220_000, "per-message baseline untouched") +}) + +test("issue #344 all-field cascade: provider-level nudgeGrowthTokens tightens the growth threshold (multi-turn)", () => { + const state = createSessionState() + state.modelContextLimit = 1_000_000 + state.nudges.lastPerMessageNudgeTokens = 100_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + state.messageIds.byRawId.set("u2", "m00003") + state.messageIds.byRawId.set("a2", "m00004") + + const config = buildConfig() + config.compress.maxContextLimit = 800_000 + config.compress.minNudgeContextPercent = 5 // global floor = 50K of 1M + // No global nudgeGrowthTokens → fixed default 50K. The provider-level + // override tightens it to 5K for every anthropic model. + config.compress.providers = { + anthropic: { nudgeGrowthTokens: 5_000 }, + } + + // Turn 1: currentTokens = 108K (98K+10K), growth = 8K. Global rule: 8K < 50K + // default threshold → no nudge. Override: 8K >= 5K and >= growthFloor + // max(5000, 0.45×5K=2250) → fires. 108K >= 50K floor ✓ + const turn1: WithParts[] = [ + userMsgWithModel("u1", "hello", "anthropic", "claude-sonnet-4-6"), + assistantMsgWithTokens("a1", "done", { input: 98_000, output: 10_000 }, [ + toolPart("c1", "x".repeat(8_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn1, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "8K growth >= 5K provider-level threshold → fires despite 50K default") + assert.equal(state.nudges.lastNudgeShownTokens, 108_000, "nudge baseline set to currentTokens") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 100_000, "per-message baseline NOT advanced by the nudge") + + // Turn 2: same growth from a non-anthropic model → default 50K threshold → + // suppressed (the override is provider-scoped). + state.nudges.lastNudgeShownTokens = undefined + const turn2: WithParts[] = [ + userMsgWithModel("u1", "hello", "openai", "gpt-5"), + assistantMsgWithTokens("a1", "done", { input: 98_000, output: 10_000 }, [ + toolPart("c1", "x".repeat(8_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn2, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, false, "8K growth < 50K default threshold for other providers → suppressed") + assert.equal(state.nudges.lastNudgeShownTokens, undefined, "no new nudge for the unknown provider") +}) + +test("issue #344: per-model floor holds in production config across the full growth cycle (preserveRecentMessages > 0)", () => { + // §5.7.1: production config (preserveRecentMessages > 0) + full growth cycle + // (baseline → growth → nudge → compress → new baseline → growth → nudge) + + // the nothingToCompress baseline-reset regression lock (PR #207) under a + // model-level floor. preserveRecentMessages: 2 keeps a compressible head in + // the 4+ message turns while making the 2-message turn fully protected. + const state = createSessionState() + state.modelContextLimit = 1_000_000 + state.nudges.lastPerMessageNudgeTokens = 100_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + state.messageIds.byRawId.set("u2", "m00003") + state.messageIds.byRawId.set("a2", "m00004") + state.messageIds.byRawId.set("u3", "m00005") + state.messageIds.byRawId.set("a3", "m00006") + state.messageIds.byRawId.set("u4", "m00007") + state.messageIds.byRawId.set("a4", "m00008") + + const config = buildConfig() + config.compress.preserveRecentMessages = 2 // production-like: last 2 messages protected + config.compress.maxContextLimit = 800_000 + config.compress.minNudgeContextPercent = 5 // global floor would be 50K + config.compress.providers = { + anthropic: { + models: { + // Model-level override raises the floor for THIS model only: + // 30% of 1M = 300K. + "claude-sonnet-4-6": { minNudgeContextPercent: 30 }, + }, + }, + } + + // Turn 1: currentTokens = 200K (180K+20K). Growth = 100K >= 50K threshold, + // but 200K < 300K model-level floor → suppressed. a1's tool output is + // compressible (only u2/a2 are in the preserve-recent zone) → this is floor + // suppression, not nothingToCompress. + const turn1: WithParts[] = [ + userMsgWithModel("u1", "hello", "anthropic", "claude-sonnet-4-6"), + assistantMsgWithTokens("a1", "done", { input: 180_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + userMsgWithModel("u2", "next", "anthropic", "claude-sonnet-4-6"), + assistantMsgWithTokens("a2", "more", { input: 180_000, output: 20_000 }), + ] + injectCompressNudges(state, config, logger, turn1, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, false, "turn 1: 200K < 300K model-level floor → suppressed in production config") + assert.equal(state.nudges.lastNudgeShownTokens, undefined, "turn 1: no nudge shown below floor") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 100_000, "turn 1: baseline preserved below floor") + + // Turn 2: currentTokens = 320K (300K+20K). Growth = 220K >= 50K. 320K >= 300K + // floor → open → nudge fires (a1's tool output still compressible). + const turn2: WithParts[] = [ + userMsgWithModel("u1", "hello", "anthropic", "claude-sonnet-4-6"), + assistantMsgWithTokens("a1", "done", { input: 180_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + userMsgWithModel("u2", "next", "anthropic", "claude-sonnet-4-6"), + assistantMsgWithTokens("a2", "more", { input: 180_000, output: 20_000 }), + userMsgWithModel("u3", "again", "anthropic", "claude-sonnet-4-6"), + assistantMsgWithTokens("a3", "result", { input: 300_000, output: 20_000 }, [ + toolPart("c3", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn2, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "turn 2: 320K >= 300K model-level floor + 220K growth → fires") + assert.equal(state.nudges.lastNudgeShownTokens, 320_000, "turn 2: nudge shown at current tokens") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 100_000, "turn 2: baseline NOT advanced by the nudge") + + // Turn 3: model compresses → baseline reset to post-compress 200K, no nudge. + const turn3: WithParts[] = [ + userMsgWithModel("u1", "hello", "anthropic", "claude-sonnet-4-6"), + assistantMsgWithTokens("a1", "done", { input: 180_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + userMsgWithModel("u2", "next", "anthropic", "claude-sonnet-4-6"), + assistantMsgWithTokens("a2", "compressed", { input: 180_000, output: 20_000 }, [ + compressToolPart("c2", "compressed"), + ]), + ] + injectCompressNudges(state, config, logger, turn3, {} as any) + assert.equal(state.nudges.lastPerMessageNudgeTokens, 200_000, "turn 3: compress resets baseline to post-compress 200K") + assert.equal(state.nudges.shouldInjectThisTurn, false, "turn 3: compress turn, no nudge") + + // Turn 4: currentTokens = 400K (380K+20K). Growth = 400K-200K = 200K >= 50K. + // 400K >= 300K floor → the nudge fires again from the NEW baseline. + const turn4: WithParts[] = [ + userMsgWithModel("u1", "hello", "anthropic", "claude-sonnet-4-6"), + assistantMsgWithTokens("a1", "done", { input: 180_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + userMsgWithModel("u2", "next", "anthropic", "claude-sonnet-4-6"), + assistantMsgWithTokens("a2", "compressed", { input: 180_000, output: 20_000 }, [ + compressToolPart("c2", "compressed"), + ]), + userMsgWithModel("u3", "again", "anthropic", "claude-sonnet-4-6"), + assistantMsgWithTokens("a3", "result", { input: 380_000, output: 20_000 }, [ + toolPart("c3", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn4, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "turn 4: 400K >= 300K floor + 200K growth from new baseline → fires again") + assert.equal(state.nudges.lastNudgeShownTokens, 400_000, "turn 4: nudge shown at current tokens") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 200_000, "turn 4: baseline NOT advanced by the nudge") + + // Turn 5 (PR #207 regression lock): only 2 messages → both in the + // preserve-recent zone → nothingToCompress. currentTokens = 320K is ABOVE + // the 300K floor with 120K growth, so the only reason this turn is silent + // is nothingToCompress — and that path must NOT reset the baseline (the + // #207 bug silently reset lastPerMessageNudgeTokens here, starving later + // nudges in short/subagent sessions). + const turn5: WithParts[] = [ + userMsgWithModel("u4", "short", "anthropic", "claude-sonnet-4-6"), + assistantMsgWithTokens("a4", "done", { input: 300_000, output: 20_000 }, [ + toolPart("c4", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn5, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, false, "turn 5: nothingToCompress (all messages protected) → silent") + assert.equal(state.nudges.lastNudgeShownTokens, 400_000, "turn 5: lastNudgeShownTokens KEPT (resetting it reintroduces the nudge loop)") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 200_000, "turn 5: baseline NOT reset by nothingToCompress (#207 regression lock)") +})