Skip to content
Closed
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
18 changes: 18 additions & 0 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -1825,6 +1842,7 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None:
api_mode=agent.api_mode,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is initialization-only, but the gateway reuses cached agents based on _CACHE_BUSTING_CONFIG_KEYS in gateway/run.py. Please include compression.model_thresholds there and add a map-only cache-invalidation test; otherwise live config edits keep the old threshold map.

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
59 changes: 55 additions & 4 deletions agent/context_compressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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,
Expand All @@ -883,6 +919,10 @@ def update_model(
self.provider = provider

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main now reapplies a 75% threshold floor below 512K on every model update (agent/context_compressor.py:898-909, commit 76381e2a8) to prevent compaction loops. Rebase this resolver into that path and specify/test whether a per-model override can intentionally bypass the floor.

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)
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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

Expand All @@ -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",
)
Expand Down
19 changes: 19 additions & 0 deletions agent/context_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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)
10 changes: 10 additions & 0 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
13 changes: 13 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading