mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(compression): prevent stale-budget retry loops
This commit is contained in:
parent
3e953ed815
commit
377244f7c8
8 changed files with 482 additions and 4 deletions
|
|
@ -1757,8 +1757,9 @@ def init_agent(
|
||||||
)
|
)
|
||||||
_config_context_length = None
|
_config_context_length = None
|
||||||
|
|
||||||
# Resolve custom_providers list once for reuse below (startup
|
# Resolve custom_providers once before route-scoping a global context pin:
|
||||||
# context-length override and plugin context-engine init).
|
# a named custom provider may keep its base URL only in this list rather
|
||||||
|
# than repeating it under ``model``.
|
||||||
try:
|
try:
|
||||||
from hermes_cli.config import get_compatible_custom_providers
|
from hermes_cli.config import get_compatible_custom_providers
|
||||||
_custom_providers = get_compatible_custom_providers(_agent_cfg)
|
_custom_providers = get_compatible_custom_providers(_agent_cfg)
|
||||||
|
|
@ -1767,6 +1768,79 @@ def init_agent(
|
||||||
if not isinstance(_custom_providers, list):
|
if not isinstance(_custom_providers, list):
|
||||||
_custom_providers = []
|
_custom_providers = []
|
||||||
|
|
||||||
|
# ``model.context_length`` describes the configured default model. A
|
||||||
|
# process launched directly with ``--model`` / ``-m`` has already replaced
|
||||||
|
# ``agent.model`` before this initializer loads config, so carrying the
|
||||||
|
# default model's explicit window into that different runtime is stale. The
|
||||||
|
# live switch/fallback paths already clear this override; keep direct-start
|
||||||
|
# overrides consistent with them and let provider metadata resolve the
|
||||||
|
# active model's window instead.
|
||||||
|
if _config_context_length is not None and isinstance(_model_cfg, dict):
|
||||||
|
_configured_default_model = str(_model_cfg.get("default") or "").strip()
|
||||||
|
_configured_default_runtime_model = _configured_default_model
|
||||||
|
_active_runtime_model = agent.model
|
||||||
|
if _configured_default_model:
|
||||||
|
try:
|
||||||
|
from hermes_cli.model_normalize import normalize_model_for_provider
|
||||||
|
|
||||||
|
_configured_default_runtime_model = normalize_model_for_provider(
|
||||||
|
_configured_default_model, agent.provider
|
||||||
|
)
|
||||||
|
_active_runtime_model = normalize_model_for_provider(
|
||||||
|
agent.model, agent.provider
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_configured_provider = str(_model_cfg.get("provider") or "").strip()
|
||||||
|
_configured_base_url = str(_model_cfg.get("base_url") or "").rstrip("/")
|
||||||
|
if not _configured_base_url and _configured_provider.lower().startswith("custom:"):
|
||||||
|
_configured_custom_name = _configured_provider.split(":", 1)[1].lower()
|
||||||
|
for _provider_entry in _custom_providers:
|
||||||
|
if not isinstance(_provider_entry, dict):
|
||||||
|
continue
|
||||||
|
if str(_provider_entry.get("name") or "").strip().lower() != _configured_custom_name:
|
||||||
|
continue
|
||||||
|
_configured_base_url = str(
|
||||||
|
_provider_entry.get("base_url") or ""
|
||||||
|
).rstrip("/")
|
||||||
|
break
|
||||||
|
_active_base_url = str(agent.base_url or "").rstrip("/")
|
||||||
|
_route_mismatch = bool(
|
||||||
|
_configured_base_url
|
||||||
|
and _active_base_url
|
||||||
|
and _configured_base_url != _active_base_url
|
||||||
|
)
|
||||||
|
if not _configured_base_url:
|
||||||
|
_active_provider = str(agent.provider or "").strip()
|
||||||
|
try:
|
||||||
|
from hermes_cli.models import normalize_provider
|
||||||
|
|
||||||
|
_configured_provider = normalize_provider(_configured_provider)
|
||||||
|
_active_provider = normalize_provider(_active_provider)
|
||||||
|
except Exception:
|
||||||
|
_configured_provider = _configured_provider.lower()
|
||||||
|
_active_provider = _active_provider.lower()
|
||||||
|
_route_mismatch = bool(
|
||||||
|
_configured_provider
|
||||||
|
and _active_provider
|
||||||
|
and _configured_provider != _active_provider
|
||||||
|
)
|
||||||
|
_model_mismatch = bool(
|
||||||
|
_configured_default_runtime_model
|
||||||
|
and _configured_default_runtime_model != _active_runtime_model
|
||||||
|
)
|
||||||
|
if _model_mismatch or _route_mismatch:
|
||||||
|
_ra().logger.debug(
|
||||||
|
"Ignoring model.context_length=%s for startup runtime %s at %s "
|
||||||
|
"(configured default is %s at %s)",
|
||||||
|
_config_context_length,
|
||||||
|
agent.model,
|
||||||
|
_active_base_url or agent.provider,
|
||||||
|
_configured_default_model,
|
||||||
|
_configured_base_url or _model_cfg.get("provider"),
|
||||||
|
)
|
||||||
|
_config_context_length = None
|
||||||
|
|
||||||
# Store for reuse by _check_compression_model_feasibility (auxiliary
|
# Store for reuse by _check_compression_model_feasibility (auxiliary
|
||||||
# compression model context-length detection needs the same list).
|
# compression model context-length detection needs the same list).
|
||||||
agent._custom_providers = _custom_providers
|
agent._custom_providers = _custom_providers
|
||||||
|
|
|
||||||
|
|
@ -436,6 +436,19 @@ def check_compression_model_feasibility(agent: Any) -> None:
|
||||||
old_threshold = threshold
|
old_threshold = threshold
|
||||||
new_threshold = aux_context
|
new_threshold = aux_context
|
||||||
agent.context_compressor.threshold_tokens = new_threshold
|
agent.context_compressor.threshold_tokens = new_threshold
|
||||||
|
# ``tail_token_budget`` is derived from the trigger threshold, not
|
||||||
|
# directly from the model window. Keep it in lockstep with this
|
||||||
|
# just-in-time correction exactly as ContextCompressor.update_model()
|
||||||
|
# does. Leaving the old budget behind can make the tail's 1.5x soft
|
||||||
|
# ceiling wider than the lowered trigger, so compression preserves
|
||||||
|
# nearly the entire request and repeatedly re-fires.
|
||||||
|
summary_target_ratio = getattr(
|
||||||
|
agent.context_compressor, "summary_target_ratio", None
|
||||||
|
)
|
||||||
|
if isinstance(summary_target_ratio, (int, float)):
|
||||||
|
agent.context_compressor.tail_token_budget = int(
|
||||||
|
new_threshold * summary_target_ratio
|
||||||
|
)
|
||||||
# Keep threshold_percent in sync so future main-model
|
# Keep threshold_percent in sync so future main-model
|
||||||
# context_length changes (update_model) re-derive from a
|
# context_length changes (update_model) re-derive from a
|
||||||
# sensible number rather than the original too-high value.
|
# sensible number rather than the original too-high value.
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ from agent.display import KawaiiSpinner
|
||||||
from agent.error_classifier import FailoverReason, classify_api_error
|
from agent.error_classifier import FailoverReason, classify_api_error
|
||||||
from agent.iteration_budget import IterationBudget
|
from agent.iteration_budget import IterationBudget
|
||||||
from agent.turn_context import (
|
from agent.turn_context import (
|
||||||
|
_compression_warrants_another_preflight_pass,
|
||||||
build_turn_context,
|
build_turn_context,
|
||||||
compose_user_api_content,
|
compose_user_api_content,
|
||||||
reanchor_current_turn_user_idx,
|
reanchor_current_turn_user_idx,
|
||||||
|
|
@ -684,6 +685,8 @@ def run_conversation(
|
||||||
truncated_tool_call_retries = 0
|
truncated_tool_call_retries = 0
|
||||||
truncated_response_parts: List[str] = []
|
truncated_response_parts: List[str] = []
|
||||||
compression_attempts = 0
|
compression_attempts = 0
|
||||||
|
_last_preflight_pressure: Optional[int] = None
|
||||||
|
_preflight_compression_blocked = _ctx.preflight_compression_blocked
|
||||||
_turn_exit_reason = "unknown" # Diagnostic: why the loop ended
|
_turn_exit_reason = "unknown" # Diagnostic: why the loop ended
|
||||||
# Last composed answer intentionally held back by a verification gate. If
|
# Last composed answer intentionally held back by a verification gate. If
|
||||||
# that continuation consumes the remaining budget, this is the best
|
# that continuation consumes the remaining budget, this is the best
|
||||||
|
|
@ -1125,6 +1128,37 @@ def run_conversation(
|
||||||
# LLM cooldown + anti-thrash guards (#11529). compression_attempts is a
|
# LLM cooldown + anti-thrash guards (#11529). compression_attempts is a
|
||||||
# hard per-turn backstop shared with the overflow error handlers.
|
# hard per-turn backstop shared with the overflow error handlers.
|
||||||
_compressor = agent.context_compressor
|
_compressor = agent.context_compressor
|
||||||
|
_preflight_threshold = int(
|
||||||
|
getattr(_compressor, "threshold_tokens", 0) or 0
|
||||||
|
)
|
||||||
|
# A previous mid-turn preflight pass deliberately continued the loop so
|
||||||
|
# API-only context and all sanitization could be rebuilt. Compare that
|
||||||
|
# fully assembled request with the fully assembled request that caused
|
||||||
|
# the pass. Raw ``messages`` are not equivalent here: they omit
|
||||||
|
# api_content/plugin injections, prefills, MoA context, and ephemeral
|
||||||
|
# system text.
|
||||||
|
_previous_preflight_pressure = _last_preflight_pressure
|
||||||
|
_last_preflight_pressure = None
|
||||||
|
if (
|
||||||
|
_previous_preflight_pressure is not None
|
||||||
|
and request_pressure_tokens >= _preflight_threshold
|
||||||
|
and not _compression_warrants_another_preflight_pass(
|
||||||
|
_previous_preflight_pressure,
|
||||||
|
request_pressure_tokens,
|
||||||
|
_preflight_threshold,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
# Stop proactive retries for this turn without consuming the
|
||||||
|
# shared overflow-recovery budget. If the provider proves the
|
||||||
|
# request truly does not fit, its error handler may still compact
|
||||||
|
# with that stronger signal.
|
||||||
|
_preflight_compression_blocked = True
|
||||||
|
logger.warning(
|
||||||
|
"Pre-API compression made insufficient progress: ~%s -> "
|
||||||
|
"~%s request tokens; skipping additional preflight passes",
|
||||||
|
f"{_previous_preflight_pressure:,}",
|
||||||
|
f"{request_pressure_tokens:,}",
|
||||||
|
)
|
||||||
_defer_preflight = getattr(
|
_defer_preflight = getattr(
|
||||||
_compressor, "should_defer_preflight_to_real_usage", lambda _t: False
|
_compressor, "should_defer_preflight_to_real_usage", lambda _t: False
|
||||||
)
|
)
|
||||||
|
|
@ -1135,6 +1169,7 @@ def run_conversation(
|
||||||
agent.compression_enabled
|
agent.compression_enabled
|
||||||
and len(messages) > 1
|
and len(messages) > 1
|
||||||
and compression_attempts < 3
|
and compression_attempts < 3
|
||||||
|
and not _preflight_compression_blocked
|
||||||
and not _defer_preflight(request_pressure_tokens)
|
and not _defer_preflight(request_pressure_tokens)
|
||||||
and not _compression_cooldown
|
and not _compression_cooldown
|
||||||
and _compressor.should_compress(request_pressure_tokens)
|
and _compressor.should_compress(request_pressure_tokens)
|
||||||
|
|
@ -1153,6 +1188,7 @@ def run_conversation(
|
||||||
f"📦 Pre-API compression: ~{request_pressure_tokens:,} tokens "
|
f"📦 Pre-API compression: ~{request_pressure_tokens:,} tokens "
|
||||||
f"near the context/output limit. Compacting before the next model call."
|
f"near the context/output limit. Compacting before the next model call."
|
||||||
)
|
)
|
||||||
|
_last_preflight_pressure = request_pressure_tokens
|
||||||
messages, active_system_prompt = agent._compress_context(
|
messages, active_system_prompt = agent._compress_context(
|
||||||
messages,
|
messages,
|
||||||
system_message,
|
system_message,
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,23 @@ def _compression_made_progress(
|
||||||
return orig_tokens > 0 and new_tokens < orig_tokens * 0.95
|
return orig_tokens > 0 and new_tokens < orig_tokens * 0.95
|
||||||
|
|
||||||
|
|
||||||
|
def _compression_warrants_another_preflight_pass(
|
||||||
|
orig_tokens: int, new_tokens: int, threshold_tokens: int
|
||||||
|
) -> bool:
|
||||||
|
"""Whether an over-threshold request merits another immediate summary.
|
||||||
|
|
||||||
|
Row-count progress is enough to prove that a compression boundary was real,
|
||||||
|
but not enough to justify another expensive pass before trying the provider.
|
||||||
|
Continue only when the request remains over threshold *and* the previous pass
|
||||||
|
materially reduced its estimated token pressure (>5%).
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
new_tokens >= threshold_tokens
|
||||||
|
and orig_tokens > 0
|
||||||
|
and new_tokens < orig_tokens * 0.95
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _should_run_preflight_estimate(
|
def _should_run_preflight_estimate(
|
||||||
messages: List[Dict[str, Any]],
|
messages: List[Dict[str, Any]],
|
||||||
protect_first_n: int,
|
protect_first_n: int,
|
||||||
|
|
@ -263,6 +280,8 @@ class TurnContext:
|
||||||
plugin_user_context: str = ""
|
plugin_user_context: str = ""
|
||||||
# External-memory prefetch result, reused across loop iterations.
|
# External-memory prefetch result, reused across loop iterations.
|
||||||
ext_prefetch_cache: str = ""
|
ext_prefetch_cache: str = ""
|
||||||
|
# Turn-start preflight already proved an immediate retry ineffective.
|
||||||
|
preflight_compression_blocked: bool = False
|
||||||
|
|
||||||
|
|
||||||
def build_turn_context(
|
def build_turn_context(
|
||||||
|
|
@ -565,6 +584,7 @@ def build_turn_context(
|
||||||
# See ``_should_run_preflight_estimate`` for the OR semantics that fix
|
# See ``_should_run_preflight_estimate`` for the OR semantics that fix
|
||||||
# issue #27405 (a few very large messages slipping past the count gate).
|
# issue #27405 (a few very large messages slipping past the count gate).
|
||||||
_preflight_compressed = False
|
_preflight_compressed = False
|
||||||
|
_preflight_compression_blocked = False
|
||||||
if agent.compression_enabled and _should_run_preflight_estimate(
|
if agent.compression_enabled and _should_run_preflight_estimate(
|
||||||
messages,
|
messages,
|
||||||
agent.context_compressor.protect_first_n,
|
agent.context_compressor.protect_first_n,
|
||||||
|
|
@ -664,6 +684,7 @@ def build_turn_context(
|
||||||
if not _compression_made_progress(
|
if not _compression_made_progress(
|
||||||
_orig_len, len(messages), _orig_tokens, _preflight_tokens
|
_orig_len, len(messages), _orig_tokens, _preflight_tokens
|
||||||
):
|
):
|
||||||
|
_preflight_compression_blocked = True
|
||||||
break # Cannot compress further: neither rows nor tokens moved
|
break # Cannot compress further: neither rows nor tokens moved
|
||||||
conversation_history = conversation_history_after_compression(
|
conversation_history = conversation_history_after_compression(
|
||||||
agent, messages
|
agent, messages
|
||||||
|
|
@ -675,6 +696,19 @@ def build_turn_context(
|
||||||
agent._mute_post_response = False
|
agent._mute_post_response = False
|
||||||
if not _compressor.should_compress(_preflight_tokens):
|
if not _compressor.should_compress(_preflight_tokens):
|
||||||
break
|
break
|
||||||
|
if not _compression_warrants_another_preflight_pass(
|
||||||
|
_orig_tokens,
|
||||||
|
_preflight_tokens,
|
||||||
|
_compressor.threshold_tokens,
|
||||||
|
):
|
||||||
|
_preflight_compression_blocked = True
|
||||||
|
logger.warning(
|
||||||
|
"Preflight compression made insufficient progress: "
|
||||||
|
"~%s -> ~%s request tokens; skipping additional passes",
|
||||||
|
f"{_orig_tokens:,}",
|
||||||
|
f"{_preflight_tokens:,}",
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
if _preflight_compressed:
|
if _preflight_compressed:
|
||||||
# Compression rebuilt the list (tail messages are fresh compaction
|
# Compression rebuilt the list (tail messages are fresh compaction
|
||||||
|
|
@ -899,4 +933,5 @@ def build_turn_context(
|
||||||
should_review_memory=should_review_memory,
|
should_review_memory=should_review_memory,
|
||||||
plugin_user_context=plugin_user_context,
|
plugin_user_context=plugin_user_context,
|
||||||
ext_prefetch_cache=ext_prefetch_cache,
|
ext_prefetch_cache=ext_prefetch_cache,
|
||||||
|
preflight_compression_blocked=_preflight_compression_blocked,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,10 @@ progress.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from agent.turn_context import _compression_made_progress
|
from agent.turn_context import (
|
||||||
|
_compression_made_progress,
|
||||||
|
_compression_warrants_another_preflight_pass,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestCompressionMadeProgress:
|
class TestCompressionMadeProgress:
|
||||||
|
|
@ -84,3 +87,26 @@ class TestCompressionMadeProgress:
|
||||||
assert _compression_made_progress(
|
assert _compression_made_progress(
|
||||||
orig_len=10, new_len=10, orig_tokens=0, new_tokens=0
|
orig_len=10, new_len=10, orig_tokens=0, new_tokens=0
|
||||||
) is False
|
) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompressionWarrantsAnotherPreflightPass:
|
||||||
|
def test_material_reduction_above_threshold_allows_another_pass(self):
|
||||||
|
assert _compression_warrants_another_preflight_pass(
|
||||||
|
orig_tokens=400_000,
|
||||||
|
new_tokens=350_000,
|
||||||
|
threshold_tokens=272_000,
|
||||||
|
) is True
|
||||||
|
|
||||||
|
def test_marginal_reduction_above_threshold_stops(self):
|
||||||
|
assert _compression_warrants_another_preflight_pass(
|
||||||
|
orig_tokens=350_000,
|
||||||
|
new_tokens=345_000,
|
||||||
|
threshold_tokens=272_000,
|
||||||
|
) is False
|
||||||
|
|
||||||
|
def test_clearing_threshold_needs_no_additional_pass(self):
|
||||||
|
assert _compression_warrants_another_preflight_pass(
|
||||||
|
orig_tokens=280_000,
|
||||||
|
new_tokens=250_000,
|
||||||
|
threshold_tokens=272_000,
|
||||||
|
) is False
|
||||||
|
|
|
||||||
|
|
@ -987,9 +987,17 @@ class TestPreflightCompression:
|
||||||
with (
|
with (
|
||||||
patch("agent.turn_context.estimate_request_tokens_rough", return_value=144_669),
|
patch("agent.turn_context.estimate_request_tokens_rough", return_value=144_669),
|
||||||
patch("agent.conversation_loop.estimate_request_tokens_rough", return_value=144_669),
|
patch("agent.conversation_loop.estimate_request_tokens_rough", return_value=144_669),
|
||||||
|
patch(
|
||||||
|
"agent.conversation_loop.estimate_messages_tokens_rough",
|
||||||
|
return_value=144_669,
|
||||||
|
),
|
||||||
# Compression no-ops (returns input unchanged) — mirrors an aux
|
# Compression no-ops (returns input unchanged) — mirrors an aux
|
||||||
# summary-model timeout where the messages can't be reduced.
|
# summary-model timeout where the messages can't be reduced.
|
||||||
patch.object(agent, "_compress_context", side_effect=lambda msgs, *a, **k: (msgs, agent._cached_system_prompt)),
|
patch.object(
|
||||||
|
agent,
|
||||||
|
"_compress_context",
|
||||||
|
side_effect=lambda msgs, *a, **k: (msgs, agent._cached_system_prompt),
|
||||||
|
) as mock_compress,
|
||||||
patch.object(agent, "_persist_session"),
|
patch.object(agent, "_persist_session"),
|
||||||
patch.object(agent, "_save_trajectory"),
|
patch.object(agent, "_save_trajectory"),
|
||||||
patch.object(agent, "_cleanup_task_resources"),
|
patch.object(agent, "_cleanup_task_resources"),
|
||||||
|
|
@ -997,6 +1005,10 @@ class TestPreflightCompression:
|
||||||
result = agent.run_conversation("hello", conversation_history=big_history)
|
result = agent.run_conversation("hello", conversation_history=big_history)
|
||||||
|
|
||||||
assert result["completed"] is True
|
assert result["completed"] is True
|
||||||
|
# A no-op pass cannot become more effective by immediately summarizing
|
||||||
|
# the same request twice more. Proceed to the provider/recovery path
|
||||||
|
# after one attempt instead of spending the full three-pass budget.
|
||||||
|
assert mock_compress.call_count == 1
|
||||||
# The display token count was revised up to the fresh preflight estimate,
|
# The display token count was revised up to the fresh preflight estimate,
|
||||||
# not left at the stale 74_400.
|
# not left at the stale 74_400.
|
||||||
assert agent.context_compressor.last_prompt_tokens == 144_669
|
assert agent.context_compressor.last_prompt_tokens == 144_669
|
||||||
|
|
@ -1030,6 +1042,43 @@ class TestPreflightCompression:
|
||||||
# Smaller estimate must not overwrite the larger tracked value.
|
# Smaller estimate must not overwrite the larger tracked value.
|
||||||
assert agent.context_compressor.last_prompt_tokens == 160_000
|
assert agent.context_compressor.last_prompt_tokens == 160_000
|
||||||
|
|
||||||
|
def test_preflight_stops_after_marginal_compression(self, agent):
|
||||||
|
"""Do not spend three summary calls removing one row per pass."""
|
||||||
|
agent.compression_enabled = True
|
||||||
|
agent.context_compressor.context_length = 200_000
|
||||||
|
agent.context_compressor.threshold_tokens = 130_000
|
||||||
|
|
||||||
|
big_history = []
|
||||||
|
for i in range(20):
|
||||||
|
big_history.append({"role": "user", "content": f"Message {i} padded text"})
|
||||||
|
big_history.append({"role": "assistant", "content": f"Response {i} padded text"})
|
||||||
|
|
||||||
|
ok_resp = _mock_response(content="After marginal preflight", finish_reason="stop")
|
||||||
|
agent.client.chat.completions.create.side_effect = [ok_resp]
|
||||||
|
|
||||||
|
def _drop_one_row(messages, *_args, **_kwargs):
|
||||||
|
return messages[:-1], agent._cached_system_prompt
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("agent.turn_context.estimate_request_tokens_rough", return_value=144_669),
|
||||||
|
patch("agent.conversation_loop.estimate_request_tokens_rough", return_value=144_669),
|
||||||
|
patch(
|
||||||
|
"agent.conversation_loop.estimate_messages_tokens_rough",
|
||||||
|
return_value=144_669,
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
agent, "_compress_context", side_effect=_drop_one_row
|
||||||
|
) as mock_compress,
|
||||||
|
patch.object(agent, "_persist_session"),
|
||||||
|
patch.object(agent, "_save_trajectory"),
|
||||||
|
patch.object(agent, "_cleanup_task_resources"),
|
||||||
|
):
|
||||||
|
result = agent.run_conversation("hello", conversation_history=big_history)
|
||||||
|
|
||||||
|
assert result["completed"] is True
|
||||||
|
assert result["final_response"] == "After marginal preflight"
|
||||||
|
assert mock_compress.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
class TestToolResultPreflightCompression:
|
class TestToolResultPreflightCompression:
|
||||||
"""Compression should trigger when tool results push context past the threshold."""
|
"""Compression should trigger when tool results push context past the threshold."""
|
||||||
|
|
@ -1072,6 +1121,59 @@ class TestToolResultPreflightCompression:
|
||||||
mock_compress.assert_called_once()
|
mock_compress.assert_called_once()
|
||||||
assert result["completed"] is True
|
assert result["completed"] is True
|
||||||
|
|
||||||
|
def test_mid_turn_retry_compares_fully_assembled_requests(self, agent):
|
||||||
|
"""API-only context must not make marginal compression look effective."""
|
||||||
|
agent.compression_enabled = True
|
||||||
|
agent.context_compressor.context_length = 200_000
|
||||||
|
agent.context_compressor.threshold_tokens = 130_000
|
||||||
|
|
||||||
|
tc = SimpleNamespace(
|
||||||
|
id="tc1", type="function",
|
||||||
|
function=SimpleNamespace(name="web_search", arguments='{"query":"test"}'),
|
||||||
|
)
|
||||||
|
tool_resp = _mock_response(
|
||||||
|
content="",
|
||||||
|
finish_reason="stop",
|
||||||
|
tool_calls=[tc],
|
||||||
|
)
|
||||||
|
ok_resp = _mock_response(
|
||||||
|
content="Done after one compression", finish_reason="stop"
|
||||||
|
)
|
||||||
|
agent.client.chat.completions.create.side_effect = [tool_resp, ok_resp]
|
||||||
|
|
||||||
|
# First provider request is small. The tool result pushes the fully
|
||||||
|
# assembled request over threshold; rebuilding after compression only
|
||||||
|
# trims it from 150K to 148K. Raw-message estimation is much smaller,
|
||||||
|
# which previously made the no-op pass look successful and allowed two
|
||||||
|
# more immediate summaries.
|
||||||
|
assembled_estimates = iter(
|
||||||
|
[1_000, 150_000, 148_000, 148_000, 148_000]
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"agent.conversation_loop.estimate_messages_tokens_rough",
|
||||||
|
side_effect=lambda *_a, **_k: next(assembled_estimates),
|
||||||
|
),
|
||||||
|
patch("run_agent.handle_function_call", return_value="x" * 100_000),
|
||||||
|
patch.object(
|
||||||
|
agent,
|
||||||
|
"_compress_context",
|
||||||
|
side_effect=lambda msgs, *_a, **_k: (
|
||||||
|
msgs,
|
||||||
|
agent._cached_system_prompt,
|
||||||
|
),
|
||||||
|
) as mock_compress,
|
||||||
|
patch.object(agent, "_persist_session"),
|
||||||
|
patch.object(agent, "_save_trajectory"),
|
||||||
|
patch.object(agent, "_cleanup_task_resources"),
|
||||||
|
):
|
||||||
|
result = agent.run_conversation("hello")
|
||||||
|
|
||||||
|
assert result["completed"] is True
|
||||||
|
assert result["final_response"] == "Done after one compression"
|
||||||
|
assert mock_compress.call_count == 1
|
||||||
|
|
||||||
def test_anthropic_prompt_too_long_safety_net(self, agent):
|
def test_anthropic_prompt_too_long_safety_net(self, agent):
|
||||||
"""Anthropic 'prompt is too long' error triggers compression as safety net."""
|
"""Anthropic 'prompt is too long' error triggers compression as safety net."""
|
||||||
err_400 = Exception(
|
err_400 = Exception(
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,10 @@ def _make_agent(
|
||||||
compressor = MagicMock(spec=ContextCompressor)
|
compressor = MagicMock(spec=ContextCompressor)
|
||||||
compressor.context_length = main_context
|
compressor.context_length = main_context
|
||||||
compressor.threshold_tokens = int(main_context * threshold_percent)
|
compressor.threshold_tokens = int(main_context * threshold_percent)
|
||||||
|
compressor.summary_target_ratio = 0.20
|
||||||
|
compressor.tail_token_budget = int(
|
||||||
|
compressor.threshold_tokens * compressor.summary_target_ratio
|
||||||
|
)
|
||||||
agent.context_compressor = compressor
|
agent.context_compressor = compressor
|
||||||
|
|
||||||
return agent
|
return agent
|
||||||
|
|
@ -96,6 +100,11 @@ def test_auto_corrects_threshold_when_aux_context_below_threshold(mock_get_clien
|
||||||
assert agent._compression_warning is not None
|
assert agent._compression_warning is not None
|
||||||
# Threshold on the live compressor was actually lowered to aux_context.
|
# Threshold on the live compressor was actually lowered to aux_context.
|
||||||
assert agent.context_compressor.threshold_tokens == 80_000
|
assert agent.context_compressor.threshold_tokens == 80_000
|
||||||
|
# Every threshold-derived budget must move with it. Keeping the original
|
||||||
|
# 20K tail here would protect 25% of the lowered threshold instead of the
|
||||||
|
# configured 20%, and larger real-world mismatches can make the tail's 1.5x
|
||||||
|
# soft ceiling wider than the entire compression trigger.
|
||||||
|
assert agent.context_compressor.tail_token_budget == 16_000
|
||||||
|
|
||||||
|
|
||||||
@patch("agent.model_metadata.get_model_context_length", return_value=32_768)
|
@patch("agent.model_metadata.get_model_context_length", return_value=32_768)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,41 @@ from run_agent import AIAgent
|
||||||
from agent.context_compressor import ContextCompressor
|
from agent.context_compressor import ContextCompressor
|
||||||
|
|
||||||
|
|
||||||
|
class _StubStartupCompressor:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.context_length = kwargs.get("config_context_length") or 272_000
|
||||||
|
self.config_context_length = kwargs.get("config_context_length")
|
||||||
|
self.threshold_tokens = int(self.context_length * 0.95)
|
||||||
|
self.threshold_percent = 0.95
|
||||||
|
|
||||||
|
def get_tool_schemas(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def on_session_start(self, *args, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _make_direct_start_agent(
|
||||||
|
cfg: dict, *, model: str, provider: str, base_url: str
|
||||||
|
) -> AIAgent:
|
||||||
|
with (
|
||||||
|
patch("hermes_cli.config.load_config", return_value=cfg),
|
||||||
|
patch("run_agent.get_tool_definitions", return_value=[]),
|
||||||
|
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||||
|
patch("run_agent.OpenAI"),
|
||||||
|
patch("agent.agent_init.ContextCompressor", new=_StubStartupCompressor),
|
||||||
|
):
|
||||||
|
return AIAgent(
|
||||||
|
model=model,
|
||||||
|
provider=provider,
|
||||||
|
api_key="fake-test-token",
|
||||||
|
base_url=base_url,
|
||||||
|
quiet_mode=True,
|
||||||
|
skip_context_files=True,
|
||||||
|
skip_memory=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _make_agent_with_compressor(config_context_length=None) -> AIAgent:
|
def _make_agent_with_compressor(config_context_length=None) -> AIAgent:
|
||||||
"""Build a minimal AIAgent with a context_compressor, skipping __init__."""
|
"""Build a minimal AIAgent with a context_compressor, skipping __init__."""
|
||||||
agent = AIAgent.__new__(AIAgent)
|
agent = AIAgent.__new__(AIAgent)
|
||||||
|
|
@ -73,3 +108,151 @@ def test_switch_model_without_config_context_length():
|
||||||
mock_ctx_len.assert_called_once()
|
mock_ctx_len.assert_called_once()
|
||||||
call_kwargs = mock_ctx_len.call_args.kwargs
|
call_kwargs = mock_ctx_len.call_args.kwargs
|
||||||
assert call_kwargs.get("config_context_length") is None
|
assert call_kwargs.get("config_context_length") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_direct_start_model_override_does_not_inherit_profile_context_length():
|
||||||
|
"""A CLI ``--model`` startup override must not inherit another model's window."""
|
||||||
|
cfg = {
|
||||||
|
"model": {
|
||||||
|
"default": "kimi-k3",
|
||||||
|
"provider": "custom:kimi-coding-1m",
|
||||||
|
"base_url": "https://api.kimi.com/coding",
|
||||||
|
"context_length": 1_048_576,
|
||||||
|
},
|
||||||
|
"custom_providers": [
|
||||||
|
{
|
||||||
|
"name": "kimi-coding-1m",
|
||||||
|
"base_url": "https://api.kimi.com/coding",
|
||||||
|
"models": {"kimi-k3": {"context_length": 1_048_576}},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
agent = _make_direct_start_agent(
|
||||||
|
cfg,
|
||||||
|
model="gpt-5.6-sol",
|
||||||
|
provider="openai-codex",
|
||||||
|
base_url="https://chatgpt.com/backend-api/codex",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert agent.context_compressor.config_context_length is None
|
||||||
|
assert agent.context_compressor.context_length == 272_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_direct_start_preserves_context_for_normalized_default_model_alias():
|
||||||
|
"""Equivalent vendor-prefixed defaults still own their explicit window."""
|
||||||
|
cfg = {
|
||||||
|
"model": {
|
||||||
|
"default": "openai/gpt-5.6-sol",
|
||||||
|
"provider": "openai-codex",
|
||||||
|
"base_url": "https://chatgpt.com/backend-api/codex",
|
||||||
|
"context_length": 272_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
agent = _make_direct_start_agent(
|
||||||
|
cfg,
|
||||||
|
model="gpt-5.6-sol",
|
||||||
|
provider="openai-codex",
|
||||||
|
base_url="https://chatgpt.com/backend-api/codex",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert agent.context_compressor.config_context_length == 272_000
|
||||||
|
assert agent.context_compressor.context_length == 272_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_direct_start_same_model_on_different_route_drops_context_override():
|
||||||
|
"""Context pins are route-specific even when the model slug is unchanged."""
|
||||||
|
cfg = {
|
||||||
|
"model": {
|
||||||
|
"default": "gpt-5.6-sol",
|
||||||
|
"provider": "custom:large-sol-route",
|
||||||
|
"base_url": "https://large-sol.example/v1",
|
||||||
|
"context_length": 1_048_576,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
agent = _make_direct_start_agent(
|
||||||
|
cfg,
|
||||||
|
model="gpt-5.6-sol",
|
||||||
|
provider="openai-codex",
|
||||||
|
base_url="https://chatgpt.com/backend-api/codex",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert agent.context_compressor.config_context_length is None
|
||||||
|
assert agent.context_compressor.context_length == 272_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_direct_start_preserves_context_for_bare_aggregator_model():
|
||||||
|
"""Aggregator normalization must compare both sides, not rewrite one side."""
|
||||||
|
cfg = {
|
||||||
|
"model": {
|
||||||
|
"default": "gpt-5.4",
|
||||||
|
"provider": "openrouter",
|
||||||
|
"context_length": 1_000_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
agent = _make_direct_start_agent(
|
||||||
|
cfg,
|
||||||
|
model="gpt-5.4",
|
||||||
|
provider="openrouter",
|
||||||
|
base_url="https://openrouter.ai/api/v1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert agent.context_compressor.config_context_length == 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_direct_start_preserves_context_for_provider_alias():
|
||||||
|
"""Canonical provider aliases identify the same route when no URL is pinned."""
|
||||||
|
cfg = {
|
||||||
|
"model": {
|
||||||
|
"default": "gemini-2.5-pro",
|
||||||
|
"provider": "google",
|
||||||
|
"context_length": 1_000_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
agent = _make_direct_start_agent(
|
||||||
|
cfg,
|
||||||
|
model="gemini-2.5-pro",
|
||||||
|
provider="gemini",
|
||||||
|
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert agent.context_compressor.config_context_length == 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_direct_start_named_custom_route_resolves_configured_base_url():
|
||||||
|
"""Named custom providers must not collapse to one generic custom route."""
|
||||||
|
cfg = {
|
||||||
|
"model": {
|
||||||
|
"default": "shared-model",
|
||||||
|
"provider": "custom:large-route",
|
||||||
|
"context_length": 1_048_576,
|
||||||
|
},
|
||||||
|
"custom_providers": [
|
||||||
|
{
|
||||||
|
"name": "large-route",
|
||||||
|
"base_url": "https://large.example/v1",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
agent = _make_direct_start_agent(
|
||||||
|
cfg,
|
||||||
|
model="shared-model",
|
||||||
|
provider="custom",
|
||||||
|
base_url="https://small.example/v1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert agent.context_compressor.config_context_length is None
|
||||||
|
assert agent.context_compressor.context_length == 272_000
|
||||||
|
|
||||||
|
matching_agent = _make_direct_start_agent(
|
||||||
|
cfg,
|
||||||
|
model="shared-model",
|
||||||
|
provider="custom",
|
||||||
|
base_url="https://large.example/v1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matching_agent.context_compressor.config_context_length == 1_048_576
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue