diff --git a/agent/agent_init.py b/agent/agent_init.py index 599927ba0c78..0bbd300e036e 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1793,6 +1793,17 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None: compression_abort_on_summary_failure = str( _compression_cfg.get("abort_on_summary_failure", False) ).lower() in {"true", "1", "yes"} + # Per-model threshold overrides: keys are substring-matched against the + # model name (longest match wins). Empty dict = use the global threshold + # for all models (backward compatible). + _raw_model_thresholds = _compression_cfg.get("model_thresholds", {}) + if isinstance(_raw_model_thresholds, dict): + compression_model_thresholds = { + str(k): float(v) for k, v in _raw_model_thresholds.items() + if isinstance(v, (int, float)) and not isinstance(v, bool) + } + else: + compression_model_thresholds = {} # In-place compaction: when True, compress_context() rewrites the message # list + rebuilds the system prompt WITHOUT rotating the session id (no # parent_session_id chain, no `name #N` renumber). See #38763 and @@ -2188,6 +2199,16 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None: provider=agent.provider, custom_providers=_custom_providers, ) + # Per-model threshold overrides are part of the explicit + # context-engine contract: assign them BEFORE the initial + # update_model() call so the first resolution (which derives + # threshold_percent/threshold_tokens for the initial model) already + # sees the overrides. Assigning after update_model() left the initial + # model on the engine's global threshold until the first /model + # switch. Engines that override update_model() own their own policy + # and may ignore the attribute. + if compression_model_thresholds: + agent.context_compressor.model_thresholds = compression_model_thresholds agent.context_compressor.update_model( model=agent.model, context_length=_plugin_ctx_len, @@ -2214,6 +2235,7 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None: api_mode=agent.api_mode, abort_on_summary_failure=compression_abort_on_summary_failure, max_tokens=agent.max_tokens, + model_thresholds=compression_model_thresholds, ) _bind_session_state = getattr(agent.context_compressor, "bind_session_state", None) if callable(_bind_session_state): diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 2884e057ba75..28850fc73833 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -856,6 +856,32 @@ def _summarize_tool_result_unguarded(tool_name: str, tool_args: str, tool_conten return f"[{tool_name}]{first_arg} ({content_len:,} chars result)" +def resolve_model_threshold( + model: str, + model_thresholds: dict[str, float] | None, + default: float, +) -> float: + """Resolve the effective compression threshold for a given model. + + ``model_thresholds`` maps substring keys to override fractions. The + longest matching key wins (so ``glm-5.2-1M`` beats ``glm-5.2`` when the + model is ``glm-5.2-1M``). When no override matches, or when + ``model_thresholds`` is empty/None, ``default`` is returned unchanged. + + This is a module-level helper so plugin context engines (e.g. LCM) can + import and reuse the same resolution logic as the built-in compressor. + """ + if not model_thresholds or not model: + return default + best_key = "" + for key in model_thresholds: + if key in model and len(key) > len(best_key): + best_key = key + if best_key: + return float(model_thresholds[best_key]) + return default + + class ContextCompressor(ContextEngine): """Default context engine — compresses conversation context via lossy summarization. @@ -1163,17 +1189,19 @@ def update_model( self.provider = provider self.api_mode = api_mode self.context_length = context_length - # Re-apply the small-context threshold floor for the NEW window, - # starting from the originally-configured percent (not the possibly - # floored live value) so a small -> large switch drops back to the - # configured threshold and a large -> small switch gains the floor. - # Guard with getattr: compressors unpickled/constructed before this - # attribute existed fall back to the live value. - _configured_pct = getattr( - self, "_configured_threshold_percent", self.threshold_percent, + # Re-resolve per-model threshold for the NEW model, then re-apply the + # small-context threshold floor. Starting from _config_threshold_percent + # (the raw config value) so a switch from a model with an override to + # one without correctly falls back to the global threshold. + _config_pct = getattr( + self, "_config_threshold_percent", self.threshold_percent, ) + _new_base = resolve_model_threshold( + model, self.model_thresholds, _config_pct, + ) + self._base_threshold_percent = _new_base self.threshold_percent = self._effective_threshold_percent( - context_length, _configured_pct, + context_length, _new_base, ) # max_tokens=None here means "caller didn't specify" → keep the existing # output reservation. A switch that genuinely changes the output budget @@ -1319,13 +1347,26 @@ def __init__( api_mode: str = "", abort_on_summary_failure: bool = False, max_tokens: int | None = None, + model_thresholds: dict[str, float] | None = None, ): self.model = model self.base_url = base_url self.api_key = api_key self.provider = provider self.api_mode = api_mode - self.threshold_percent = threshold_percent + # Per-model threshold overrides (longest substring match wins). + # Stored as a plain dict; resolved in _resolve_threshold(), then the + # small-context floor is applied on top. + self.model_thresholds = model_thresholds or {} + # _config_threshold_percent is the raw config value (before per-model + # override or small-context floor). Used as the fallback when switching + # to a model with no matching override. + self._config_threshold_percent = threshold_percent + # Resolve per-model override first, then apply the small-context floor. + self._base_threshold_percent = resolve_model_threshold( + model, self.model_thresholds, threshold_percent, + ) + self.threshold_percent = self._base_threshold_percent self.protect_first_n = protect_first_n self.protect_last_n = protect_last_n self.summary_target_ratio = max(0.10, min(summary_target_ratio, 0.80)) @@ -1355,9 +1396,11 @@ def __init__( # resolved and BEFORE threshold_tokens is derived. The pre-floor # value is kept so update_model() can re-derive for a new window # (switching small -> large must drop back to the configured value). + # Note: _base_threshold_percent already has the per-model override + # applied, so the floor stacks on top of any model-specific threshold. self._configured_threshold_percent = self.threshold_percent self.threshold_percent = self._effective_threshold_percent( - self.context_length, self.threshold_percent, + self.context_length, self._base_threshold_percent, ) threshold_percent = self.threshold_percent # Floor: never compress below MINIMUM_CONTEXT_LENGTH tokens even if diff --git a/agent/context_engine.py b/agent/context_engine.py index 13b2341fe02f..eafbb5ddc2d8 100644 --- a/agent/context_engine.py +++ b/agent/context_engine.py @@ -260,4 +260,19 @@ def update_model( (e.g. recalculate DAG budgets, switch summary models). """ self.context_length = context_length + # Apply per-model threshold overrides if set (longest substring match). + # Falls back to _config_threshold_percent (the raw config value) when + # no override matches. Plugin engines that override update_model() can + # call resolve_model_threshold() for the same logic. + from agent.context_compressor import resolve_model_threshold + if not hasattr(self, "_config_threshold_percent"): + # Snapshot the pre-override percent ONCE so repeated model + # switches fall back to the engine's configured value, not the + # previous model's override. + self._config_threshold_percent = self.threshold_percent + self._base_threshold_percent = resolve_model_threshold( + model, getattr(self, "model_thresholds", {}), + self._config_threshold_percent, + ) + self.threshold_percent = self._base_threshold_percent self.threshold_tokens = int(context_length * self.threshold_percent) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index d12d4ff63052..39a752ef41d4 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -414,6 +414,16 @@ compression: # compaction doesn't fire with half the window still free; set above 0.75 to override. threshold: 0.50 + # Per-model threshold overrides: keys are substring-matched against the model + # name (longest match wins). Useful when some models need different compaction + # points — e.g. a 1M-context model can compress later (0.30) while a 128K + # model needs to compress earlier (0.60). The small-context floor (75% for + # <512K models) still applies on top of per-model overrides. + # model_thresholds: + # "glm-5.2": 0.40 + # "claude-sonnet": 0.35 + # "gpt-5": 0.30 + # Existing Codex gpt-5.5 behavior: raise Hermes' compaction trigger to 85% # for the ChatGPT Codex OAuth route. Set false to opt back down to threshold. codex_gpt55_autoraise: true diff --git a/contributors/emails/ben@whetstone.com.au b/contributors/emails/ben@whetstone.com.au new file mode 100644 index 000000000000..895a6856280b --- /dev/null +++ b/contributors/emails/ben@whetstone.com.au @@ -0,0 +1 @@ +bennybuoy diff --git a/gateway/run.py b/gateway/run.py index 0bbb976258cd..d5dbde129028 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -17425,6 +17425,7 @@ async def _run_process_watcher(self, watcher: dict) -> None: ("model", "max_tokens"), ("compression", "enabled"), ("compression", "threshold"), + ("compression", "model_thresholds"), ("compression", "codex_gpt55_autoraise"), ("compression", "codex_app_server_auto"), ("compression", "target_ratio"), diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7a527b75f53c..31f35ff1b781 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1529,6 +1529,17 @@ def _ensure_hermes_home_managed(home: Path): # session_search and recoverable, not deleted. # Default False during rollout; will flip on # after live validation. + "model_thresholds": {}, # Per-model threshold overrides. Keys are + # substring-matched against the model name + # (longest match wins); values replace the + # global `threshold` for that model, e.g. + # model_thresholds: + # "glm-5.2": 0.40 + # "claude-sonnet": 0.35 + # The small-context floor (0.75 for <512K + # models) still applies on top of overrides + # (raise-only: an override above the floor + # wins; one below it is raised to the floor). }, # Kanban subsystem (orchestrator workers + dispatcher-driven child tasks). diff --git a/tests/run_agent/test_per_model_compression_threshold.py b/tests/run_agent/test_per_model_compression_threshold.py new file mode 100644 index 000000000000..575cd6b165e4 --- /dev/null +++ b/tests/run_agent/test_per_model_compression_threshold.py @@ -0,0 +1,233 @@ +"""Tests for per-model compression threshold overrides. + +Users who swap between models with very different context windows (e.g. a +256K model and a 1M model) need different compaction trigger points. +``compression.model_thresholds`` in config.yaml lets them set per-model +overrides that are resolved by longest substring match. The small-context +floor (75% for <512K models) still applies on top of per-model overrides. +""" + +from unittest.mock import patch + +from agent.context_compressor import ContextCompressor, resolve_model_threshold +from agent.context_engine import ContextEngine + + +# --------------------------------------------------------------------------- +# resolve_model_threshold helper +# --------------------------------------------------------------------------- + +class TestResolveModelThreshold: + def test_no_overrides_returns_default(self): + assert resolve_model_threshold("glm-5.2", None, 0.50) == 0.50 + assert resolve_model_threshold("glm-5.2", {}, 0.50) == 0.50 + + def test_empty_model_returns_default(self): + assert resolve_model_threshold("", {"glm": 0.70}, 0.50) == 0.50 + + def test_exact_match(self): + overrides = {"glm-5.2": 0.70} + assert resolve_model_threshold("glm-5.2", overrides, 0.50) == 0.70 + + def test_substring_match(self): + overrides = {"glm-5.2": 0.70} + assert resolve_model_threshold("openai/glm-5.2", overrides, 0.50) == 0.70 + + def test_longest_match_wins(self): + overrides = {"glm-5.2": 0.70, "glm-5.2-1M": 0.25} + # "glm-5.2-1M" is a longer match than "glm-5.2" + assert resolve_model_threshold("glm-5.2-1M", overrides, 0.50) == 0.25 + # "glm-5.2" alone still matches at 0.70 + assert resolve_model_threshold("glm-5.2", overrides, 0.50) == 0.70 + + def test_no_match_returns_default(self): + overrides = {"claude-sonnet-4": 0.60} + assert resolve_model_threshold("glm-5.2", overrides, 0.50) == 0.50 + + def test_override_can_lower_threshold(self): + """Per-model overrides work in both directions (raise and lower).""" + overrides = {"small-model": 0.30} + assert resolve_model_threshold("small-model", overrides, 0.50) == 0.30 + + +# --------------------------------------------------------------------------- +# ContextCompressor integration +# --------------------------------------------------------------------------- + +class TestContextCompressorModelThresholds: + @patch("agent.context_compressor.get_model_context_length", return_value=1_000_000) + def test_init_large_context_with_override(self, _mock): + """Large context (>=512K) + per-model override: override applies directly.""" + cc = ContextCompressor( + model="glm-5.2", + threshold_percent=0.50, + model_thresholds={"glm-5.2": 0.40}, + quiet_mode=True, + ) + # 1M context >= 512K, so no small-context floor — override wins + assert cc.threshold_percent == 0.40 + assert cc.threshold_tokens == int(1_000_000 * 0.40) + + @patch("agent.context_compressor.get_model_context_length", return_value=1_000_000) + def test_init_large_context_no_match(self, _mock): + """Large context + no matching override: global threshold used.""" + cc = ContextCompressor( + model="glm-5.2", + threshold_percent=0.50, + model_thresholds={"claude-sonnet-4": 0.60}, + quiet_mode=True, + ) + assert cc.threshold_percent == 0.50 + assert cc.threshold_tokens == int(1_000_000 * 0.50) + + @patch("agent.context_compressor.get_model_context_length", return_value=256_000) + def test_init_small_context_override_below_floor(self, _mock): + """Small context (<512K) + override below 75%: floor wins (raise-only).""" + cc = ContextCompressor( + model="glm-5.2", + threshold_percent=0.50, + model_thresholds={"glm-5.2": 0.40}, + quiet_mode=True, + ) + # 256K < 512K → floor at 0.75; override 0.40 < 0.75, so floor wins + assert cc.threshold_percent == 0.75 + assert cc.threshold_tokens == int(256_000 * 0.75) + + @patch("agent.context_compressor.get_model_context_length", return_value=256_000) + def test_init_small_context_override_above_floor(self, _mock): + """Small context + override above 75%: override wins.""" + cc = ContextCompressor( + model="glm-5.2", + threshold_percent=0.50, + model_thresholds={"glm-5.2": 0.80}, + quiet_mode=True, + ) + # 256K < 512K → floor at 0.75; override 0.80 > 0.75, so override wins + assert cc.threshold_percent == 0.80 + + @patch("agent.context_compressor.get_model_context_length", return_value=256_000) + def test_init_no_model_thresholds_dict(self, _mock): + """Empty model_thresholds dict = backward compatible.""" + cc = ContextCompressor( + model="glm-5.2", + threshold_percent=0.50, + quiet_mode=True, + ) + # 256K < 512K → floored at 0.75 + assert cc.threshold_percent == 0.75 + assert cc.model_thresholds == {} + + @patch("agent.context_compressor.get_model_context_length", return_value=256_000) + def test_init_none_model_thresholds(self, _mock): + """Passing None for model_thresholds is safe.""" + cc = ContextCompressor( + model="glm-5.2", + threshold_percent=0.50, + model_thresholds=None, + quiet_mode=True, + ) + assert cc.model_thresholds == {} + + @patch("agent.context_compressor.get_model_context_length") + def test_update_model_re_resolves_threshold(self, mock_ctx): + """Switching models re-resolves the per-model threshold + re-applies floor.""" + mock_ctx.return_value = 256_000 + cc = ContextCompressor( + model="glm-5.2", + threshold_percent=0.50, + model_thresholds={"glm-5.2": 0.80, "glm-5.2-1M": 0.25}, + quiet_mode=True, + ) + # 256K < 512K → floor at 0.75; override 0.80 > 0.75, so 0.80 wins + assert cc.threshold_percent == 0.80 + + # Switch to the 1M model (large context, no floor) + mock_ctx.return_value = 1_000_000 + cc.update_model( + model="glm-5.2-1M", + context_length=1_000_000, + ) + # 1M >= 512K → no floor; override 0.25 applies directly + assert cc.threshold_percent == 0.25 + assert cc.threshold_tokens == int(1_000_000 * 0.25) + + @patch("agent.context_compressor.get_model_context_length") + def test_update_model_falls_back_to_global(self, mock_ctx): + """Switching to a model with no override uses the global threshold.""" + mock_ctx.return_value = 1_000_000 + cc = ContextCompressor( + model="glm-5.2", + threshold_percent=0.50, + model_thresholds={"glm-5.2": 0.40}, + quiet_mode=True, + ) + # 1M context, override 0.40 + assert cc.threshold_percent == 0.40 + + # Switch to a model with no override (still large context) + mock_ctx.return_value = 1_000_000 + cc.update_model( + model="some-other-model", + context_length=1_000_000, + ) + # No override match → falls back to global 0.50; 1M >= 512K → no floor + assert cc.threshold_percent == 0.50 + assert cc.threshold_tokens == int(1_000_000 * 0.50) + + +# --------------------------------------------------------------------------- +# ContextEngine base class +# --------------------------------------------------------------------------- + +class TestContextEngineModelThresholds: + def test_base_class_update_model_applies_overrides(self): + """The base-class update_model() applies model_thresholds if set.""" + class TestEngine(ContextEngine): + @property + def name(self): + return "test" + + def update_from_response(self, usage): + pass + + def should_compress(self, prompt_tokens=None): + return False + + def compress(self, messages, current_tokens=None, focus_topic=None): + return messages + + engine = TestEngine() + engine.threshold_percent = 0.50 + engine._config_threshold_percent = 0.50 + engine.context_length = 0 + engine.model_thresholds = {"glm-5.2-1M": 0.25} + + engine.update_model(model="glm-5.2-1M", context_length=1_000_000) + assert engine.threshold_percent == 0.25 + assert engine.threshold_tokens == int(1_000_000 * 0.25) + + def test_base_class_update_model_no_overrides(self): + """Without model_thresholds, the base class behaves as before.""" + class TestEngine(ContextEngine): + @property + def name(self): + return "test" + + def update_from_response(self, usage): + pass + + def should_compress(self, prompt_tokens=None): + return False + + def compress(self, messages, current_tokens=None, focus_topic=None): + return messages + + engine = TestEngine() + engine.threshold_percent = 0.50 + engine._base_threshold_percent = 0.50 + engine.context_length = 0 + engine.model_thresholds = {} + + engine.update_model(model="glm-5.2", context_length=256_000) + assert engine.threshold_percent == 0.50 + assert engine.threshold_tokens == int(256_000 * 0.50) diff --git a/tests/run_agent/test_per_model_threshold_init_ordering.py b/tests/run_agent/test_per_model_threshold_init_ordering.py new file mode 100644 index 000000000000..fa01a62c3c97 --- /dev/null +++ b/tests/run_agent/test_per_model_threshold_init_ordering.py @@ -0,0 +1,186 @@ +"""Follow-up regression tests for per-model compression threshold overrides. + +Covers the gaps flagged in the review of PR #63020: + +1. Plugin-engine init ordering — ``compression.model_thresholds`` must be + assigned to a selected plugin context engine BEFORE the initial + ``update_model()`` call in agent init, so the initial model already gets + its override (previously the override only took effect after the first + ``/model`` switch). +2. ``compression.model_thresholds`` is a public key in ``DEFAULT_CONFIG``. +3. Floor interaction on the ``update_model()`` (model-switch) path: + an override below the small-context floor is raised to the floor + (raise-only); an override above the floor wins. +4. Base-class ``update_model()`` snapshots the pre-override percent once, + so repeated switches fall back to the engine's configured threshold + rather than a previous model's override. +""" + +from unittest.mock import patch + +from agent.context_compressor import ContextCompressor +from agent.context_engine import ContextEngine + + +class _StubEngine(ContextEngine): + """Minimal concrete context engine for init-ordering tests.""" + + @property + def name(self) -> str: + return "stub" + + def update_from_response(self, usage): + pass + + def should_compress(self, prompt_tokens=None): + return False + + def compress(self, messages, current_tokens=None): + return messages + + +def test_plugin_engine_gets_model_thresholds_before_initial_update_model(): + """The initial model's override must apply during AIAgent init. + + Regression test for the PR #63020 review finding: the plugin engine was + initialized through update_model() before model_thresholds was assigned, + so the initial model kept the global threshold until a /model switch. + """ + engine = _StubEngine() + engine.threshold_percent = 0.50 + + cfg = { + "context": {"engine": "stub"}, + "agent": {}, + "compression": { + "threshold": 0.50, + "model_thresholds": {"glm-5.2": 0.25}, + }, + } + + with ( + patch("hermes_cli.config.load_config", return_value=cfg), + patch("plugins.context_engine.load_context_engine", return_value=engine), + patch("agent.model_metadata.get_model_context_length", return_value=1_000_000), + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + from run_agent import AIAgent + + agent = AIAgent( + model="glm-5.2", + api_key="test-key-1234567890", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + assert agent.context_compressor is engine + # The override map arrived before the initial update_model() call, so the + # very first resolution already used it. + assert engine.model_thresholds == {"glm-5.2": 0.25} + assert engine.threshold_percent == 0.25 + assert engine.threshold_tokens == int(1_000_000 * 0.25) + + +def test_plugin_engine_without_overrides_keeps_global_threshold(): + """Empty model_thresholds leaves plugin-engine init behavior unchanged.""" + engine = _StubEngine() + engine.threshold_percent = 0.50 + + cfg = { + "context": {"engine": "stub"}, + "agent": {}, + "compression": {"threshold": 0.50}, + } + + with ( + patch("hermes_cli.config.load_config", return_value=cfg), + patch("plugins.context_engine.load_context_engine", return_value=engine), + patch("agent.model_metadata.get_model_context_length", return_value=1_000_000), + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + from run_agent import AIAgent + + agent = AIAgent( + model="glm-5.2", + api_key="test-key-1234567890", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + assert agent.context_compressor is engine + assert getattr(engine, "model_thresholds", {}) == {} + assert engine.threshold_percent == 0.50 + assert engine.threshold_tokens == int(1_000_000 * 0.50) + + +def test_model_thresholds_key_in_default_config(): + """compression.model_thresholds is a public DEFAULT_CONFIG key.""" + from hermes_cli.config import DEFAULT_CONFIG + + assert "model_thresholds" in DEFAULT_CONFIG["compression"] + assert DEFAULT_CONFIG["compression"]["model_thresholds"] == {} + + +class TestFloorInteractionOnModelSwitch: + """The small-context floor stacks on per-model overrides at switch time.""" + + @patch("agent.context_compressor.get_model_context_length") + def test_switch_override_below_floor_is_raised_to_floor(self, mock_ctx): + """Switching to a small-context model with a sub-floor override → floor.""" + mock_ctx.return_value = 1_000_000 + cc = ContextCompressor( + model="glm-5.2-1M", + threshold_percent=0.50, + model_thresholds={"glm-5.2-1M": 0.25, "small-model": 0.40}, + quiet_mode=True, + ) + assert cc.threshold_percent == 0.25 # large context: override direct + + # Switch to a <512K model whose override (0.40) is below the 0.75 floor. + mock_ctx.return_value = 128_000 + cc.update_model(model="small-model", context_length=128_000) + assert cc.threshold_percent == 0.75 # raise-only floor wins + assert cc.threshold_tokens == int(128_000 * 0.75) + + @patch("agent.context_compressor.get_model_context_length") + def test_switch_override_above_floor_wins(self, mock_ctx): + """Switching to a small-context model with an above-floor override → override.""" + mock_ctx.return_value = 1_000_000 + cc = ContextCompressor( + model="glm-5.2-1M", + threshold_percent=0.50, + model_thresholds={"glm-5.2-1M": 0.25, "small-model": 0.85}, + quiet_mode=True, + ) + mock_ctx.return_value = 128_000 + cc.update_model(model="small-model", context_length=128_000) + assert cc.threshold_percent == 0.85 # above the 0.75 floor: override wins + assert cc.threshold_tokens == int(128_000 * 0.85) + + +class TestBaseEngineConfigSnapshot: + """Base-class update_model() must not compound a previous override.""" + + def test_repeated_switches_fall_back_to_original_threshold(self): + engine = _StubEngine() + engine.threshold_percent = 0.50 + engine.context_length = 0 + engine.model_thresholds = {"glm-5.2-1M": 0.25} + # NOTE: _config_threshold_percent deliberately NOT pre-set — the base + # class must snapshot the original 0.50 on the first call, so the + # second switch (no matching override) falls back to 0.50, not 0.25. + + engine.update_model(model="glm-5.2-1M", context_length=1_000_000) + assert engine.threshold_percent == 0.25 + + engine.update_model(model="some-other-model", context_length=1_000_000) + assert engine.threshold_percent == 0.50 + assert engine.threshold_tokens == int(1_000_000 * 0.50) diff --git a/website/docs/developer-guide/context-compression-and-caching.md b/website/docs/developer-guide/context-compression-and-caching.md index aa817190c174..74dfea7ead62 100644 --- a/website/docs/developer-guide/context-compression-and-caching.md +++ b/website/docs/developer-guide/context-compression-and-caching.md @@ -82,6 +82,9 @@ All compression settings are read from `config.yaml` under the `compression` key compression: enabled: true # Enable/disable compression (default: true) threshold: 0.50 # Fraction of context window (default: 0.50 = 50%) + # model_thresholds: # Per-model threshold overrides (substring match, + # "glm-5.2": 0.40 # longest key wins). See "Per-model threshold + # "claude-sonnet": 0.35 # overrides" below. target_ratio: 0.20 # How much of threshold to keep as tail (default: 0.20) protect_last_n: 20 # Minimum protected tail messages (default: 20) codex_gpt55_autoraise: true # gpt-5.5 on Codex OAuth: raise trigger to 85% (default: true) @@ -101,6 +104,7 @@ auxiliary: | Parameter | Default | Range | Description | |-----------|---------|-------|-------------| | `threshold` | `0.50` | 0.0-1.0 | Compression triggers when prompt tokens ≥ `threshold × context_length` | +| `model_thresholds` | `{}` | map | Per-model overrides of `threshold`. Keys are substring-matched against the model name (longest match wins). The small-context floor still applies on top (see below) | | `target_ratio` | `0.20` | 0.10-0.80 | Controls tail protection token budget: `threshold_tokens × target_ratio` | | `protect_last_n` | `20` | ≥1 | Minimum number of recent messages always preserved | | `protect_first_n` | `3` | (hardcoded) | System prompt + first exchange always preserved | @@ -108,6 +112,39 @@ auxiliary: | `codex_gpt55_autoraise_notice` | `true` | bool | Show the one-time Codex gpt-5.5 autoraise notice. Set `false` to keep the 85% autoraise but suppress the banner | | `codex_app_server_auto` | `native` | `native`, `hermes`, `off` | Thread-compaction mode for Codex app-server sessions (see below) | +### Per-model threshold overrides + +`compression.model_thresholds` lets you trigger compaction at different points +depending on the active model — useful when you swap between models with very +different context windows (e.g. a 1M-context model can compress later while a +128K model should compress earlier): + +```yaml +compression: + threshold: 0.50 + model_thresholds: + "glm-5.2": 0.40 + "glm-5.2-1M": 0.25 + "claude-sonnet": 0.35 +``` + +Resolution rules: + +- Keys are **substring-matched** against the model name; the **longest + matching key wins** (`glm-5.2-1M` beats `glm-5.2` for model `glm-5.2-1M`). +- When no key matches (or the map is empty), the global `threshold` applies. +- The override is re-resolved on every `/model` switch; switching to a model + with no matching key falls back to the global `threshold`. +- The **small-context floor still applies on top** of overrides (raise-only): + models with context windows below 512K are floored at `0.75`, so an + override below the floor is raised to `0.75`, while an override above it + (e.g. `0.80`) wins. + +Plugin context engines can reuse the same resolution logic via +`from agent.context_compressor import resolve_model_threshold`; engines that +override `update_model()` own their own compaction policy and may ignore the +map. + ### Codex gpt-5.5 threshold autoraise The ChatGPT Codex OAuth backend hard-caps gpt-5.5 at a **272K** context window diff --git a/website/docs/developer-guide/context-engine-plugin.md b/website/docs/developer-guide/context-engine-plugin.md index c1ce4366e533..a6e53de9dbf4 100644 --- a/website/docs/developer-guide/context-engine-plugin.md +++ b/website/docs/developer-guide/context-engine-plugin.md @@ -165,7 +165,7 @@ context: engine: "lcm" # must match your engine's name property ``` -The `compression` config block (`compression.threshold`, `compression.protect_last_n`, etc.) is specific to the built-in `ContextCompressor`. Your engine should define its own config format if needed, reading from `config.yaml` during initialization. +The `compression` config block (`compression.threshold`, `compression.protect_last_n`, etc.) is specific to the built-in `ContextCompressor`, with one explicit exception: `compression.model_thresholds` (per-model threshold overrides) is part of the context-engine contract. The host assigns the resolved map to `engine.model_thresholds` *before* the initial `update_model()` call, and the base-class `update_model()` applies it (longest substring match, falling back to the engine's configured threshold). Engines that override `update_model()` own their own compaction policy and may honor or ignore the map — `from agent.context_compressor import resolve_model_threshold` to reuse the same resolution logic. For everything else, your engine should define its own config format if needed, reading from `config.yaml` during initialization. ## Testing