mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
fix: close review gaps for per-model threshold overrides (#63020)
Follow-up to the salvaged contributor commit, closing the three gaps flagged in the sweeper review: 1. Init ordering: assign compression.model_thresholds to a selected plugin context engine BEFORE the initial update_model() call in agent_init.py, so the initial model's override applies from init (previously it only took effect after the first /model switch). Base-class ContextEngine.update_model() now snapshots the pre-override percent once so repeated switches fall back to the engine's configured threshold, not a previous model's override. 2. DEFAULT_CONFIG: add compression.model_thresholds (empty map) to hermes_cli/config.py — additive key, no _config_version bump. 3. Docs: document the key in website/docs/developer-guide/context-compression-and-caching.md (yaml example, parameter table, dedicated section) and update the plugin-boundary note in context-engine-plugin.md to state the explicit context-engine contract for model_thresholds. Adds tests/run_agent/test_per_model_threshold_init_ordering.py: plugin-engine AIAgent init regression (override applies at init, empty map unchanged), DEFAULT_CONFIG key presence, floor interaction on the model-switch path (override below the small-context floor is raised to the floor; above the floor wins), and base-class config snapshot across repeated switches. Also maps @bennybuoy in contributors/emails/.
This commit is contained in:
parent
5f2fdf66bf
commit
f944e84858
8 changed files with 254 additions and 7 deletions
|
|
@ -2234,6 +2234,16 @@ def init_agent(
|
|||
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,
|
||||
|
|
@ -2242,9 +2252,6 @@ 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:
|
||||
|
|
|
|||
|
|
@ -265,9 +265,14 @@ class ContextEngine(ABC):
|
|||
# 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)
|
||||
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", {}), _config_pct,
|
||||
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)
|
||||
|
|
|
|||
1
contributors/emails/ben@whetstone.com.au
Normal file
1
contributors/emails/ben@whetstone.com.au
Normal file
|
|
@ -0,0 +1 @@
|
|||
bennybuoy
|
||||
|
|
@ -1534,6 +1534,17 @@ DEFAULT_CONFIG = {
|
|||
# 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).
|
||||
|
|
|
|||
|
|
@ -230,4 +230,4 @@ class TestContextEngineModelThresholds:
|
|||
|
||||
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)
|
||||
assert engine.threshold_tokens == int(256_000 * 0.50)
|
||||
|
|
|
|||
186
tests/run_agent/test_per_model_threshold_init_ordering.py
Normal file
186
tests/run_agent/test_per_model_threshold_init_ordering.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue