Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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):
Expand Down
65 changes: 54 additions & 11 deletions agent/context_compressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions agent/context_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
10 changes: 10 additions & 0 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions contributors/emails/ben@whetstone.com.au
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
bennybuoy
1 change: 1 addition & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
11 changes: 11 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading
Loading