mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
feat: per-model compression threshold overrides (v2, rebased on main)
Addresses teknium1 review feedback on PR #60781: 1. Gateway cache invalidation: added ('compression', 'model_thresholds') to _CACHE_BUSTING_CONFIG_KEYS so a live config edit to the map invalidates the cached compressor (previously kept stale thresholds). 2. Integrated resolver with small-context floor: per-model overrides are resolved FIRST, then the existing 75% floor for <512K models is applied on top. The floor is no longer replaced — it stacks. An override below 75% on a small-context model still gets floored to 75% (raise-only); an override above 75% wins. 3. Clean rebase on upstream main — no unrelated deletions or anti-thrashing changes. Only the per-model threshold feature is added. Changes: - resolve_model_threshold() module-level helper (longest substring match) - ContextCompressor.__init__ accepts model_thresholds dict - _base_threshold_percent stores the per-model resolved value - _config_threshold_percent stores the raw config value (fallback base) - update_model() re-resolves on /model switch, falls back to config value - ContextEngine base class update_model() applies overrides for plugin engines - agent_init.py reads compression.model_thresholds from config, passes to ctor - gateway/run.py cache busting key added - cli-config.yaml.example documents the feature - 17 tests covering resolve helper, compressor init (large/small context, override above/below floor), update_model (re-resolve, fallback), base class Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
f453c50b6f
commit
5f2fdf66bf
6 changed files with 323 additions and 11 deletions
|
|
@ -1828,6 +1828,17 @@ def init_agent(
|
|||
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
|
||||
|
|
@ -2231,6 +2242,9 @@ def init_agent(
|
|||
provider=agent.provider,
|
||||
api_mode=agent.api_mode,
|
||||
)
|
||||
# Propagate per-model threshold overrides to plugin engines.
|
||||
if compression_model_thresholds:
|
||||
agent.context_compressor.model_thresholds = compression_model_thresholds
|
||||
if not agent.quiet_mode:
|
||||
_ra().logger.info("Using context engine: %s", _selected_engine.name)
|
||||
else:
|
||||
|
|
@ -2249,6 +2263,7 @@ def init_agent(
|
|||
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):
|
||||
|
|
|
|||
|
|
@ -874,6 +874,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.
|
||||
|
||||
|
|
@ -1183,17 +1209,19 @@ class ContextCompressor(ContextEngine):
|
|||
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
|
||||
|
|
@ -1339,13 +1367,26 @@ class ContextCompressor(ContextEngine):
|
|||
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))
|
||||
|
|
@ -1375,9 +1416,11 @@ class ContextCompressor(ContextEngine):
|
|||
# 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
|
||||
|
|
|
|||
|
|
@ -260,4 +260,14 @@ class ContextEngine(ABC):
|
|||
(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
|
||||
_config_pct = getattr(self, "_config_threshold_percent", self.threshold_percent)
|
||||
self._base_threshold_percent = resolve_model_threshold(
|
||||
model, getattr(self, "model_thresholds", {}), _config_pct,
|
||||
)
|
||||
self.threshold_percent = self._base_threshold_percent
|
||||
self.threshold_tokens = int(context_length * self.threshold_percent)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17643,6 +17643,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
("model", "max_tokens"),
|
||||
("compression", "enabled"),
|
||||
("compression", "threshold"),
|
||||
("compression", "model_thresholds"),
|
||||
("compression", "codex_gpt55_autoraise"),
|
||||
("compression", "codex_app_server_auto"),
|
||||
("compression", "target_ratio"),
|
||||
|
|
|
|||
233
tests/run_agent/test_per_model_compression_threshold.py
Normal file
233
tests/run_agent/test_per_model_compression_threshold.py
Normal file
|
|
@ -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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue