diff --git a/.gitignore b/.gitignore index e4240ea36e75..91feef447942 100644 --- a/.gitignore +++ b/.gitignore @@ -144,3 +144,4 @@ apps/desktop/demo/ # PR body is the archive. See the hermes-agent-dev skill's # pr-infographic-workflow reference (storage rule + lapse #8 / #COMMIT-1). infographic/ +build/ diff --git a/agent/agent_init.py b/agent/agent_init.py index 495260d21886..a7b8420940b9 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1562,6 +1562,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 @@ -1799,6 +1810,12 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None: provider=agent.provider, custom_providers=_custom_providers, ) + # Propagate per-model threshold overrides to plugin engines. The + # base-class update_model() applies them automatically; plugin + # engines that override update_model() can read self.model_thresholds + # and call resolve_model_threshold() for the same logic. + 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, @@ -1825,6 +1842,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 45eb25e1ca0b..d1f3ab7d27f8 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -696,6 +696,32 @@ def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> 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. @@ -866,6 +892,16 @@ def _clear_compression_failure_cooldown(self) -> None: except Exception as exc: logger.debug("compression failure cooldown clear failed (non-sqlite): %s", exc) + def _effective_threshold_percent(self, model: str) -> float: + """Return the threshold for *model* after applying per-model overrides. + + Falls back to ``self._base_threshold_percent`` (the global + ``compression.threshold`` config value) when no override matches. + """ + return resolve_model_threshold( + model, self.model_thresholds, self._base_threshold_percent, + ) + def update_model( self, model: str, @@ -883,6 +919,10 @@ def update_model( self.provider = provider self.api_mode = api_mode self.context_length = context_length + # Re-resolve per-model threshold on switch — the new model may have + # a different override, or the user may be switching between a 256K + # and a 1M context model that need very different compaction points. + self.threshold_percent = self._effective_threshold_percent(model) # max_tokens=None here means "caller didn't specify" → keep the existing # output reservation. A switch that genuinely changes the output budget # passes the new value explicitly. (#43547) @@ -1003,13 +1043,18 @@ 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 _effective_threshold(). + self.model_thresholds = model_thresholds or {} + self._base_threshold_percent = threshold_percent + self.threshold_percent = self._effective_threshold_percent(model) 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)) @@ -1039,7 +1084,7 @@ def __init__( # guards the degenerate case where the floor would equal/exceed the # window (small models), so auto-compression can still fire (#14690). self.threshold_tokens = self._compute_threshold_tokens( - self.context_length, threshold_percent, self.max_tokens, + self.context_length, self.threshold_percent, self.max_tokens, ) self.compression_count = 0 @@ -1051,12 +1096,18 @@ def __init__( ) if not quiet_mode: + _override_note = "" + if self.threshold_percent != self._base_threshold_percent: + _override_note = " (per-model override from %.0f%%)" % ( + self._base_threshold_percent * 100, + ) logger.info( "Context compressor initialized: model=%s context_length=%d " - "threshold=%d (%.0f%%) target_ratio=%.0f%% tail_budget=%d " + "threshold=%d (%.0f%%%s) target_ratio=%.0f%% tail_budget=%d " "provider=%s base_url=%s", model, self.context_length, self.threshold_tokens, - threshold_percent * 100, self.summary_target_ratio * 100, + self.threshold_percent * 100, _override_note, + self.summary_target_ratio * 100, self.tail_token_budget, provider or "none", base_url or "none", ) diff --git a/agent/context_engine.py b/agent/context_engine.py index ba2da561fa11..9672644cd4c4 100644 --- a/agent/context_engine.py +++ b/agent/context_engine.py @@ -62,6 +62,7 @@ def name(self) -> str: # historical "system + first 3 non-system messages" head shape. threshold_percent: float = 0.75 + _base_threshold_percent: float = 0.75 protect_first_n: int = 3 protect_last_n: int = 6 @@ -212,6 +213,14 @@ def get_status(self) -> Dict[str, Any]: # -- Optional: model switch support ------------------------------------ + # Per-model threshold overrides (longest substring match wins). + # Engines that support per-model thresholds should read this dict + # in their update_model() override. The base class stores it but + # does not use it (threshold_percent is left untouched). + # Use None as sentinel (not a mutable class-level {}) so instances + # don't share the same dict object. + model_thresholds: dict | None = None + def update_model( self, model: str, @@ -228,4 +237,14 @@ def update_model( (e.g. recalculate DAG budgets, switch summary models). """ self.context_length = context_length + # Apply per-model threshold override if configured. Engines that + # override update_model() should call resolve_model_threshold() from + # agent.context_compressor for the same logic. + if self.model_thresholds and model: + from agent.context_compressor import resolve_model_threshold + self.threshold_percent = resolve_model_threshold( + model, self.model_thresholds, self._base_threshold_percent, + ) + else: + 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 5e4bc2331771..6fdfdc3224cb 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -415,6 +415,16 @@ compression: # for the ChatGPT Codex OAuth route. Set false to opt back down to threshold. codex_gpt55_autoraise: true + # Per-model threshold overrides. Keys are matched as substrings against the + # model name (longest match wins). When a key matches, its value replaces + # the global `threshold` above for that model. Useful when switching between + # models with very different context windows (e.g. 256K vs 1M tokens). + # Example: + # model_thresholds: + # glm-5.2-1M: 0.25 # 1M window → compact at ~250K + # glm-5.2: 0.70 # 256K window → compact at ~179K + model_thresholds: {} + # Fraction of the threshold to preserve as recent tail (default: 0.20 = 20%) # e.g. 20% of 50% threshold = 10% of total context kept as recent messages. # Summary output is separately capped at 12K tokens (Gemini output limit). diff --git a/hermes_cli/config.py b/hermes_cli/config.py index e24cc220f4fa..7741116d7f8e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1441,6 +1441,19 @@ def _ensure_hermes_home_managed(home: Path): # Hermes' compression threshold triggers # thread/compact/start; off = never auto-trigger # (codex may still compact natively). + "model_thresholds": {}, # Per-model compression threshold overrides. + # Keys are matched as substrings against the + # model name (longest match wins). Example: + # model_thresholds: + # glm-5.2-1M: 0.25 # 1M window → compact at ~250K + # glm-5.2: 0.70 # 256K window → compact at ~179K + # When empty (default), the global `threshold` + # above applies to all models. Matched values + # override the global threshold unconditionally + # (both raise and lower), giving the user full + # control. The existing codex_gpt55_autoraise + # and Arcee Trinity hardcoded overrides still + # apply on top of this config. "in_place": True, # When True, compaction rewrites the message # list and rebuilds the system prompt WITHOUT # rotating the session id — the conversation 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..b7dd6b94807f --- /dev/null +++ b/tests/run_agent/test_per_model_compression_threshold.py @@ -0,0 +1,208 @@ +"""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. +""" + +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 + + def test_override_can_raise_threshold(self): + overrides = {"big-model": 0.85} + assert resolve_model_threshold("big-model", overrides, 0.50) == 0.85 + + +# --------------------------------------------------------------------------- +# ContextCompressor with model_thresholds +# --------------------------------------------------------------------------- + +class TestContextCompressorModelThresholds: + @patch("agent.context_compressor.get_model_context_length", return_value=256_000) + def test_init_applies_per_model_override(self, _mock): + """When model_thresholds contains a match, __init__ uses the override.""" + cc = ContextCompressor( + model="glm-5.2", + threshold_percent=0.50, + model_thresholds={"glm-5.2": 0.70}, + quiet_mode=True, + ) + assert cc.threshold_percent == 0.70 + assert cc._base_threshold_percent == 0.50 + # threshold_tokens should be 70% of 256K = 179,200 + assert cc.threshold_tokens == int(256_000 * 0.70) + + @patch("agent.context_compressor.get_model_context_length", return_value=1_000_000) + def test_init_no_override_uses_global(self, _mock): + """When no model_thresholds match, __init__ uses the global threshold.""" + 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_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, + ) + assert cc.threshold_percent == 0.50 + 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.threshold_percent == 0.50 + 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.""" + mock_ctx.return_value = 256_000 + cc = ContextCompressor( + model="glm-5.2", + threshold_percent=0.50, + model_thresholds={"glm-5.2": 0.70, "glm-5.2-1M": 0.25}, + quiet_mode=True, + ) + assert cc.threshold_percent == 0.70 + + # Switch to the 1M model + mock_ctx.return_value = 1_000_000 + cc.update_model( + model="glm-5.2-1M", + context_length=1_000_000, + ) + 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 = 256_000 + cc = ContextCompressor( + model="glm-5.2", + threshold_percent=0.50, + model_thresholds={"glm-5.2": 0.70}, + quiet_mode=True, + ) + assert cc.threshold_percent == 0.70 + + # Switch to a model with no override + mock_ctx.return_value = 128_000 + cc.update_model( + model="some-other-model", + context_length=128_000, + ) + assert cc.threshold_percent == 0.50 + assert cc.threshold_tokens == int(128_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.""" + # Create a minimal concrete engine for testing + 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.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) \ No newline at end of file diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 46afc11bcfd2..1262a166e51e 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -742,6 +742,9 @@ compression: protect_last_n: 20 # Min recent messages to keep uncompressed protect_first_n: 3 # Non-system head messages pinned across compactions (0 = pin nothing) hygiene_hard_message_limit: 5000 # Gateway safety valve — see below + model_thresholds: # Per-model threshold overrides (longest substring match wins) + glm-5.2-1M: 0.25 # e.g. 1M window → compact at ~250K + glm-5.2: 0.70 # e.g. 256K window → compact at ~179K # The summarization model/provider is configured under auxiliary: auxiliary: @@ -759,6 +762,20 @@ Older configs with `compression.summary_model`, `compression.summary_provider`, `protect_first_n` controls how many **non-system** head messages are pinned across every compaction. Default `3` — the opening user/assistant exchange survives every summarizer pass so the original goal stays visible. On long-running rolling-compaction sessions where the opening turn is no longer relevant, set `protect_first_n: 0` to pin nothing but the system prompt + summary + tail. The system prompt itself is always preserved regardless of this setting. +#### Per-model threshold overrides + +`model_thresholds` lets you set different compaction trigger points for different models. Keys are matched as substrings against the model name — the **longest match wins** — and the matched value replaces the global `threshold` for that model. This is essential when switching between models with very different context windows (e.g. a 256K model and a 1M model) where a single global threshold can't be optimal for both. + +```yaml +compression: + threshold: 0.50 # global default + model_thresholds: + glm-5.2-1M: 0.25 # 1M window → compact at ~250K (earlier DAG building) + glm-5.2: 0.70 # 256K window → compact at ~179K (more raw context) +``` + +Overrides work in both directions — they can raise or lower the threshold relative to the global value. The existing `codex_gpt55_autoraise` and Arcee Trinity hardcoded overrides still apply on top of this config. Plugin context engines (e.g. LCM) receive the dict via `self.model_thresholds` and can resolve thresholds the same way using `resolve_model_threshold()` from `agent.context_compressor`. + :::tip Gateway hot-reload of compression and context length As of recent releases, editing `model.context_length` or any `compression.*` key in `config.yaml` on a running gateway takes effect on the next message — no gateway restart, no `/reset`, no session rotation required. The cached-agent signature includes these keys, so the gateway transparently rebuilds the agent when it sees a change. API keys and tool/skill config still require the usual reload paths. :::