mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
- Remove tests/-shadowing sys.path.insert(dirname/'..') from 11 test files: it prepended the tests/ dir itself to sys.path, so 'import agent' / 'import hermes_cli' resolved to the test packages and collection died with ModuleNotFoundError depending on import order (2 files failed in every full-suite run; 9 more were latent). - Patch call_llm in 5 context-compressor tests that called compress() unmocked: each burned ~50s attempting live LLM traffic through the relay before falling back (572s file — the slowest in the suite, and flaky under the 300s per-file timeout). File now runs in ~5s. - agent/redact.py: fix two catastrophically-backtracking regexes hit by the compressor's redaction pass on large payloads — _STRICT_URL_USERINFO_RE anchors on the mandatory '//' (optional-scheme prefix backtracked O(n^2): ~55s on a 320KB payload, now sub-ms; output-equivalence fuzz-verified on 20k random strings), and the _CFG_DOTTED_RE/_CFG_ANCHORED_RE subs gain an exact linear keyword pre-gate so secret-free text skips the quadratic pattern entirely. - tests/gateway/test_feishu.py: version-guard the extra_ua_tags SDK signature check; the repo pins lark-oapi==1.6.8 but stale local installs (1.5.3) fail the assertion — skip below the pin. - tests/tools/test_managed_browserbase_and_modal.py: stub agent.redact + agent.credential_persistence in the fake agent package (empty __path__ blocks all real agent.* imports added since the fake was written). - tests/gateway/test_startup_restart_race.py: raise wait_for timeouts 2s -> 30s; 2s wall-clock on a loaded 40-worker box flaked in the baseline run (passes instantly when the box is quiet).
2696 lines
116 KiB
Python
2696 lines
116 KiB
Python
"""Tests for agent/context_compressor.py — compression logic, thresholds, truncation fallback."""
|
|
|
|
import json
|
|
import pytest
|
|
import time
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
from agent.context_compressor import (
|
|
ContextCompressor,
|
|
HISTORICAL_TASK_HEADING,
|
|
SUMMARY_PREFIX,
|
|
COMPRESSED_SUMMARY_METADATA_KEY,
|
|
_summarize_tool_result,
|
|
_is_summary_access_or_quota_error,
|
|
)
|
|
from hermes_state import SessionDB
|
|
|
|
|
|
class StubProviderError(Exception):
|
|
def __init__(self, message, *, status_code=None, response=None):
|
|
super().__init__(message)
|
|
self.status_code = status_code
|
|
self.response = response
|
|
|
|
|
|
@pytest.fixture()
|
|
def compressor():
|
|
"""Create a ContextCompressor with mocked dependencies."""
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(
|
|
model="test/model",
|
|
threshold_percent=0.85,
|
|
protect_first_n=2,
|
|
protect_last_n=2,
|
|
quiet_mode=True,
|
|
)
|
|
# Resolve context_length while the mock is still active so the
|
|
# fixture returns a fully-initialized compressor.
|
|
_ = c.context_length
|
|
return c
|
|
|
|
|
|
class TestSummarizeToolResultWebExtract:
|
|
"""Pre-compression pruning must survive web_extract calls whose ``urls`` are
|
|
web_search result dicts ({"url"/"href": ...}), which models routinely forward
|
|
straight into web_extract.
|
|
"""
|
|
|
|
CONTENT = "x" * 500 # >200 chars so the pruning pass actually summarizes
|
|
|
|
|
|
def test_single_dict_url_is_unwrapped_not_stringified(self):
|
|
args = json.dumps({"urls": [{"url": "https://example.com/a", "title": "A"}]})
|
|
summary = _summarize_tool_result("web_extract", args, self.CONTENT)
|
|
assert summary == "[web_extract] https://example.com/a (500 chars)"
|
|
assert "{" not in summary # no raw dict repr leaked into the summary
|
|
|
|
def test_href_key_is_unwrapped(self):
|
|
args = json.dumps({"urls": [{"href": "https://example.com/h"}]})
|
|
summary = _summarize_tool_result("web_extract", args, self.CONTENT)
|
|
assert summary == "[web_extract] https://example.com/h (500 chars)"
|
|
|
|
|
|
|
|
|
|
class TestShouldCompress:
|
|
def test_below_threshold(self, compressor):
|
|
compressor.last_prompt_tokens = 50000
|
|
assert compressor.should_compress() is False
|
|
|
|
def test_above_threshold(self, compressor):
|
|
compressor.last_prompt_tokens = 90000
|
|
assert compressor.should_compress() is True
|
|
|
|
|
|
def test_explicit_tokens(self, compressor):
|
|
assert compressor.should_compress(prompt_tokens=90000) is True
|
|
assert compressor.should_compress(prompt_tokens=50000) is False
|
|
|
|
|
|
|
|
class TestUpdateFromResponse:
|
|
def test_updates_fields(self, compressor):
|
|
compressor.awaiting_real_usage_after_compression = True
|
|
compressor.last_compression_rough_tokens = 90_000
|
|
compressor.update_from_response({
|
|
"prompt_tokens": 5000,
|
|
"completion_tokens": 1000,
|
|
"total_tokens": 6000,
|
|
})
|
|
assert compressor.last_prompt_tokens == 5000
|
|
assert compressor.last_completion_tokens == 1000
|
|
assert compressor.last_real_prompt_tokens == 5000
|
|
assert compressor.last_rough_tokens_when_real_prompt_fit == 90_000
|
|
assert compressor.awaiting_real_usage_after_compression is False
|
|
|
|
def test_missing_fields_default_zero(self, compressor):
|
|
compressor.update_from_response({})
|
|
assert compressor.last_prompt_tokens == 0
|
|
|
|
class TestPreflightDeferral:
|
|
|
|
def test_does_not_defer_when_rough_growth_is_large(self, compressor):
|
|
compressor.threshold_tokens = 85_000
|
|
compressor.last_real_prompt_tokens = 50_000
|
|
compressor.last_rough_tokens_when_real_prompt_fit = 90_000
|
|
|
|
assert compressor.should_defer_preflight_to_real_usage(100_000) is False
|
|
|
|
|
|
def test_defers_immediately_after_compaction_with_stale_real_prompt(self, compressor):
|
|
"""#36718: right after a compaction, last_real_prompt_tokens still holds
|
|
the stale pre-compression value (above threshold). The awaiting flag
|
|
must force deferral so preflight doesn't fire a SECOND compaction before
|
|
real post-compaction usage arrives."""
|
|
compressor.threshold_tokens = 85_000
|
|
# Stale pre-compression value — would hit the `>= threshold => False`
|
|
# short-circuit and defeat deferral without the flag guard.
|
|
compressor.last_real_prompt_tokens = 120_000
|
|
compressor.awaiting_real_usage_after_compression = True
|
|
assert compressor.should_defer_preflight_to_real_usage(95_000) is True
|
|
|
|
|
|
|
|
|
|
class TestCompress:
|
|
def _make_messages(self, n):
|
|
return [{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"} for i in range(n)]
|
|
|
|
|
|
|
|
|
|
def test_fallback_summary_does_not_triplicate_latest_user_ask(self):
|
|
"""Regression for #49307: the deterministic fallback summary used to
|
|
render the latest user ask verbatim under THREE headings (Task
|
|
Snapshot, In-Progress, Pending Asks). The model then re-answered it
|
|
and buried the genuinely-new post-compaction turn (answer repetition +
|
|
new-instruction loss). The latest ask must appear ONCE, as historical
|
|
context only — never re-presented as unfulfilled in-progress/pending
|
|
work.
|
|
"""
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(model="test/model", quiet_mode=True)
|
|
|
|
unique_ask = "PLEASE_COMPUTE_THE_ARITHMETIC_CHAIN_XYZ"
|
|
turns = [
|
|
{"role": "user", "content": unique_ask},
|
|
{"role": "assistant", "content": "working on it"},
|
|
]
|
|
summary = c._build_static_fallback_summary(turns, reason="provider down")
|
|
|
|
# The triplication bug rendered the SAME ``active_task`` line —
|
|
# formatted as ``User asked: '<ask>'`` — verbatim under three
|
|
# headings (Task Snapshot, In-Progress, Pending Asks), making the
|
|
# model treat an already-handled ask as unresolved work and re-answer
|
|
# it. That exact formatted line must now appear at most ONCE (only as
|
|
# the historical Task Snapshot record). The raw ask text may still
|
|
# appear elsewhere (e.g. the "Last Dropped Turns" verbatim transcript),
|
|
# but never re-labeled as in-progress/pending work.
|
|
active_task_line = f"User asked: {unique_ask!r}"
|
|
count = summary.count(active_task_line)
|
|
assert count <= 1, (
|
|
f"active_task line should appear at most once (was triplicated in "
|
|
f"#49307), found {count}x:\n{summary}"
|
|
)
|
|
|
|
def test_threshold_below_window_at_minimum_ctx(self):
|
|
"""Regression for #14690: at context_length == MINIMUM_CONTEXT_LENGTH
|
|
the floored threshold used to equal the whole window, so
|
|
auto-compression could never fire. It now triggers at 85% of the
|
|
window — high enough not to waste the small budget, below 100% so it
|
|
actually fires."""
|
|
from agent.context_compressor import MINIMUM_CONTEXT_LENGTH
|
|
t = ContextCompressor._compute_threshold_tokens(MINIMUM_CONTEXT_LENGTH, 0.50)
|
|
assert t < MINIMUM_CONTEXT_LENGTH
|
|
assert t == 54400 # 85% of 64000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_max_tokens_coercion_treats_non_int_as_no_reservation(self):
|
|
"""A non-int / non-positive max_tokens must coerce safely so the
|
|
threshold arithmetic never raises. Guards the path where a mocked
|
|
parent agent forwards a MagicMock max_tokens into a child
|
|
ContextCompressor (regression for the delegate-test TypeError:
|
|
'<=' not supported between MagicMock and int)."""
|
|
from unittest.mock import MagicMock
|
|
assert ContextCompressor._coerce_max_tokens(None) is None
|
|
assert ContextCompressor._coerce_max_tokens(0) is None
|
|
assert ContextCompressor._coerce_max_tokens(-5) is None
|
|
assert ContextCompressor._coerce_max_tokens("nope") is None
|
|
assert ContextCompressor._coerce_max_tokens(65536) == 65536
|
|
# The actual regression: building a compressor with a MagicMock
|
|
# max_tokens must NOT raise (the unmocked code did `ctx - MagicMock`
|
|
# then `MagicMock <= 0`). int(MagicMock()) returns 1, so coercion
|
|
# yields a harmless positive int rather than crashing — the threshold
|
|
# is computed cleanly with a 1-token reservation.
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=200000):
|
|
c = ContextCompressor(model="m", quiet_mode=True, max_tokens=MagicMock())
|
|
assert isinstance(c.max_tokens, int)
|
|
assert isinstance(c.threshold_tokens, int)
|
|
assert c.threshold_tokens > 0 # no crash, sane value
|
|
|
|
|
|
|
|
def test_compress_strips_db_persisted_from_assembled_messages(self, compressor):
|
|
"""Regression for #57491: shallow copies must not carry flush markers."""
|
|
msgs = [
|
|
{"role": "user" if i % 2 == 0 else "assistant", "content": f"m{i}", "_db_persisted": True}
|
|
for i in range(10)
|
|
]
|
|
with patch("agent.context_compressor.call_llm", side_effect=RuntimeError("no provider")):
|
|
result = compressor.compress(msgs)
|
|
assert len(result) < len(msgs)
|
|
assert all("_db_persisted" not in msg for msg in result)
|
|
|
|
def test_compress_terminal_sweep_strips_markers_even_if_a_copy_site_leaks(self, compressor):
|
|
"""Regression for #57491, structural: even if a copy site fails to strip
|
|
the marker (simulating a future refactor that adds/reintroduces a leaky
|
|
copy), the single terminal sweep in compress() guarantees no compacted
|
|
message leaves carrying `_db_persisted`. Neuter the per-site helper to a
|
|
plain leaking copy and assert the invariant still holds."""
|
|
import agent.context_compressor as _cc
|
|
|
|
msgs = [
|
|
{"role": "user" if i % 2 == 0 else "assistant", "content": f"m{i}", "_db_persisted": True}
|
|
for i in range(10)
|
|
]
|
|
# Make the per-site helper leak the marker (dict.copy keeps it).
|
|
with patch.object(_cc, "_fresh_compaction_message_copy", lambda m: m.copy()), \
|
|
patch("agent.context_compressor.call_llm", side_effect=RuntimeError("no provider")):
|
|
result = compressor.compress(msgs)
|
|
assert len(result) < len(msgs)
|
|
assert all("_db_persisted" not in msg for msg in result), (
|
|
"terminal sweep must strip _db_persisted even when a copy site leaks"
|
|
)
|
|
|
|
def test_protect_first_n_decays_after_first_compression(self):
|
|
"""Regression for #11996: protect_first_n must protect early turns on
|
|
the FIRST compaction but DECAY afterwards, so the same early user
|
|
messages don't get re-copied verbatim into every child session and
|
|
fossilize (grow immortal) across a long, repeatedly-compressed
|
|
session. The system prompt is always protected separately."""
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=3)
|
|
|
|
msgs = [{"role": "system", "content": "sys"}] + [
|
|
{"role": "user" if i % 2 == 0 else "assistant", "content": f"m{i}"}
|
|
for i in range(10)
|
|
]
|
|
|
|
# First compaction: protect system + first 3 non-system.
|
|
assert c.compression_count == 0
|
|
assert c._effective_protect_first_n() == 3
|
|
assert c._protect_head_size(msgs) == 1 + 3
|
|
|
|
# Simulate having compressed once — early turns now live in the summary.
|
|
c.compression_count = 1
|
|
assert c._effective_protect_first_n() == 0
|
|
assert c._protect_head_size(msgs) == 1 # system prompt only
|
|
|
|
|
|
|
|
class TestTailBudgetCodexReplayFields:
|
|
def test_tail_cut_counts_codex_replay_and_reasoning_fields(self):
|
|
"""Tail protection must budget hidden replay fields sent back to providers.
|
|
|
|
Codex Responses messages can have tiny visible content but large
|
|
`codex_reasoning_items`, `codex_message_items`, or provider-native
|
|
reasoning fields. Preflight compression counts these fields, so the
|
|
tail-cut budget must count them too; otherwise compression preserves an
|
|
oversized tail and immediately starts the next session near the limit.
|
|
"""
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(
|
|
model="test/model",
|
|
protect_first_n=1,
|
|
protect_last_n=1,
|
|
quiet_mode=True,
|
|
)
|
|
|
|
big_replay = "x" * 5_000
|
|
big_hidden_message = {
|
|
"role": "assistant",
|
|
"content": "ok",
|
|
"reasoning": "summary " + big_replay,
|
|
"reasoning_content": "scratchpad " + big_replay,
|
|
"reasoning_details": [{"text": "details " + big_replay}],
|
|
"codex_reasoning_items": [
|
|
{"type": "reasoning", "encrypted_content": "enc_" + big_replay}
|
|
],
|
|
"codex_message_items": [
|
|
{
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "reply " + big_replay}],
|
|
}
|
|
],
|
|
}
|
|
messages = [
|
|
{"role": "system", "content": "sys"},
|
|
{"role": "user", "content": "initial ask"},
|
|
{"role": "assistant", "content": "first answer"},
|
|
{"role": "user", "content": "older follow-up"},
|
|
big_hidden_message,
|
|
]
|
|
messages.extend(
|
|
{
|
|
"role": "user" if i % 2 == 0 else "assistant",
|
|
"content": f"tail visible message {i}",
|
|
}
|
|
for i in range(14)
|
|
)
|
|
|
|
cut_idx = c._find_tail_cut_by_tokens(messages, head_end=1, token_budget=150)
|
|
|
|
assert cut_idx == 5
|
|
assert messages[4]["codex_reasoning_items"][0]["encrypted_content"].startswith("enc_")
|
|
assert messages[4]["codex_message_items"][0]["content"][0]["text"].startswith("reply ")
|
|
|
|
@pytest.mark.parametrize(
|
|
("field_name", "field_value"),
|
|
[
|
|
("reasoning", "x" * 5_000),
|
|
("reasoning_content", "x" * 5_000),
|
|
("reasoning_details", [{"text": "x" * 5_000}]),
|
|
(
|
|
"codex_reasoning_items",
|
|
[{"type": "reasoning", "encrypted_content": "x" * 5_000}],
|
|
),
|
|
(
|
|
"codex_message_items",
|
|
[
|
|
{
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "x" * 5_000}],
|
|
}
|
|
],
|
|
),
|
|
],
|
|
)
|
|
def test_tail_cut_counts_each_hidden_replay_field(self, field_name, field_value):
|
|
"""Each provider replay/reasoning field should affect tail budgeting."""
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(
|
|
model="test/model",
|
|
protect_first_n=1,
|
|
protect_last_n=1,
|
|
quiet_mode=True,
|
|
)
|
|
|
|
hidden_message = {"role": "assistant", "content": "ok", field_name: field_value}
|
|
messages = [
|
|
{"role": "system", "content": "sys"},
|
|
{"role": "user", "content": "initial ask"},
|
|
{"role": "assistant", "content": "first answer"},
|
|
{"role": "user", "content": "older follow-up"},
|
|
hidden_message,
|
|
]
|
|
messages.extend(
|
|
{
|
|
"role": "user" if i % 2 == 0 else "assistant",
|
|
"content": f"tail visible message {i}",
|
|
}
|
|
for i in range(14)
|
|
)
|
|
|
|
assert c._find_tail_cut_by_tokens(messages, head_end=1, token_budget=150) == 5
|
|
|
|
|
|
class TestGenerateSummaryNoneContent:
|
|
"""Regression: content=None (from tool-call-only assistant messages) must not crash."""
|
|
|
|
def test_none_content_does_not_crash(self):
|
|
mock_response = MagicMock()
|
|
mock_response.choices = [MagicMock()]
|
|
mock_response.choices[0].message.content = "[CONTEXT SUMMARY]: tool calls happened"
|
|
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(model="test", quiet_mode=True)
|
|
|
|
messages = [
|
|
{"role": "user", "content": "do something"},
|
|
{"role": "assistant", "content": None, "tool_calls": [
|
|
{"function": {"name": "search"}}
|
|
]},
|
|
{"role": "tool", "content": "result"},
|
|
{"role": "assistant", "content": None},
|
|
{"role": "user", "content": "thanks"},
|
|
]
|
|
|
|
with patch("agent.context_compressor.call_llm", return_value=mock_response):
|
|
summary = c._generate_summary(messages)
|
|
assert isinstance(summary, str)
|
|
assert summary.startswith(SUMMARY_PREFIX)
|
|
|
|
def test_none_content_in_system_message_compress(self):
|
|
"""System message with content=None should not crash during compress."""
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2)
|
|
|
|
msgs = [{"role": "system", "content": None}] + [
|
|
{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"}
|
|
for i in range(10)
|
|
]
|
|
with patch("agent.context_compressor.call_llm", side_effect=RuntimeError("no provider")):
|
|
result = c.compress(msgs)
|
|
assert len(result) < len(msgs)
|
|
|
|
|
|
class TestNonStringContent:
|
|
"""Regression: content as dict (e.g., llama.cpp tool calls) must not crash."""
|
|
|
|
|
|
def test_none_content_treated_as_failure_not_empty_summary(self):
|
|
"""Regression #11978/#11914: a well-formed response with ``content=None``
|
|
(some OpenAI-compatible proxies, e.g. cmkey.cn, return HTTP 200 with
|
|
null/empty content) must NOT be stored as a prefix-only summary that
|
|
silently wipes the compacted turns. It is treated as a summary failure
|
|
and routed through cooldown so the turns are dropped without a summary
|
|
rather than replaced by an empty one."""
|
|
mock_response = MagicMock()
|
|
mock_response.choices = [MagicMock()]
|
|
mock_response.choices[0].message.content = None
|
|
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
# summary_model == model here, so no fallback path: straight to cooldown.
|
|
c = ContextCompressor(model="test", quiet_mode=True)
|
|
|
|
messages = [
|
|
{"role": "user", "content": "do something"},
|
|
{"role": "assistant", "content": "ok"},
|
|
]
|
|
|
|
with patch("agent.context_compressor.call_llm", return_value=mock_response):
|
|
summary = c._generate_summary(messages)
|
|
# Empty content → failure → None (drop turns), NOT a prefix-only summary.
|
|
assert summary is None
|
|
assert summary != SUMMARY_PREFIX
|
|
# Transient cooldown engaged so we don't immediately retry the bad proxy.
|
|
assert c._summary_failure_cooldown_until > 0
|
|
|
|
|
|
|
|
def test_string_message_coerced_to_summary_content(self):
|
|
mock_response = MagicMock()
|
|
mock_response.choices = [MagicMock()]
|
|
mock_response.choices[0].message = "plain summary text"
|
|
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(model="test", quiet_mode=True)
|
|
|
|
messages = [
|
|
{"role": "user", "content": "do something"},
|
|
{"role": "assistant", "content": "ok"},
|
|
]
|
|
|
|
with patch("agent.context_compressor.call_llm", return_value=mock_response):
|
|
summary = c._generate_summary(messages)
|
|
|
|
assert summary.startswith(f"{SUMMARY_PREFIX}\n{HISTORICAL_TASK_HEADING}\n")
|
|
assert "do something" in summary
|
|
assert summary.endswith("plain summary text")
|
|
|
|
|
|
|
|
|
|
def test_task_snapshot_skips_synthetic_user_scaffolding(self):
|
|
"""Grounding must anchor on the human ask, not runtime scaffolding.
|
|
|
|
Todo snapshots, truncation notices, and background-process reports
|
|
are injected with role="user"; if the newest user turn is one of
|
|
those, the deterministic snapshot must look past it to the real ask
|
|
(and return None when no real ask exists at all).
|
|
"""
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(model="test", quiet_mode=True)
|
|
|
|
messages = [
|
|
{"role": "user", "content": "fix the login bug on prod"},
|
|
{"role": "assistant", "content": "on it"},
|
|
{
|
|
"role": "user",
|
|
"content": "[Your active task list was preserved across context compression]\n- item",
|
|
"_todo_snapshot_synthetic": True,
|
|
},
|
|
]
|
|
snapshot = c._latest_user_task_snapshot(messages)
|
|
assert snapshot is not None
|
|
assert "fix the login bug on prod" in snapshot
|
|
assert "task list was preserved" not in snapshot
|
|
|
|
only_synthetic = [
|
|
{"role": "user", "content": "[System: Your previous response was truncated ...]"},
|
|
]
|
|
assert c._latest_user_task_snapshot(only_synthetic) is None
|
|
|
|
def test_grounding_preserves_following_sections_across_regrounding(self):
|
|
"""The snapshot rewrite must keep later headings intact — twice.
|
|
|
|
A replacement that consumes the section's trailing newlines glues the
|
|
next "## " heading mid-line; on the following iterative compaction the
|
|
heading is no longer at line start, the regex matches through \\Z, and
|
|
every later section is silently deleted.
|
|
"""
|
|
import re as _re
|
|
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(model="test", quiet_mode=True)
|
|
|
|
summary = (
|
|
f"{HISTORICAL_TASK_HEADING}\n"
|
|
"User asked: stale example\n\n"
|
|
"## Historical Remaining Work\n- keep me\n\n"
|
|
"## Goal\nfinish"
|
|
)
|
|
turns = [{"role": "user", "content": "real ask"}]
|
|
|
|
first = c._ground_historical_task_snapshot(summary, turns)
|
|
assert _re.search(r"(?m)^## Historical Remaining Work$", first)
|
|
assert "- keep me" in first and "## Goal" in first
|
|
|
|
second = c._ground_historical_task_snapshot(first, turns)
|
|
assert "- keep me" in second and "## Goal" in second
|
|
assert second.count(HISTORICAL_TASK_HEADING) == 1
|
|
|
|
|
|
|
|
class TestSummaryFailureCooldown:
|
|
def test_summary_failure_enters_cooldown_and_skips_retry(self):
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(model="test", quiet_mode=True)
|
|
|
|
messages = [
|
|
{"role": "user", "content": "do something"},
|
|
{"role": "assistant", "content": "ok"},
|
|
]
|
|
|
|
with patch("agent.context_compressor.call_llm", side_effect=Exception("boom")) as mock_call:
|
|
first = c._generate_summary(messages)
|
|
second = c._generate_summary(messages)
|
|
|
|
assert first is None
|
|
assert second is None
|
|
assert mock_call.call_count == 1
|
|
|
|
|
|
class TestAuthFailureAborts:
|
|
"""A 401/403 on the summary call must ABORT compression (preserve the
|
|
session unchanged) instead of rotating into a degraded child session
|
|
with a placeholder summary — regardless of abort_on_summary_failure.
|
|
|
|
Real incident: a nous token pointed at a stale staging inference URL
|
|
401'd on every compression attempt, and because abort_on_summary_failure
|
|
defaults False the session rotated anyway (messages N->N), stranding the
|
|
user on a fresh-but-broken session that kept failing the same way.
|
|
"""
|
|
|
|
def _msgs(self, n=10):
|
|
return [
|
|
{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"}
|
|
for i in range(n)
|
|
]
|
|
|
|
def _auth_err(self, status=401):
|
|
return StubProviderError(
|
|
f"Error code: {status} - "
|
|
"{'status': 401, 'message': 'Your API key is invalid, blocked or out of funds.'}",
|
|
status_code=status,
|
|
)
|
|
|
|
|
|
def test_missing_provider_api_key_is_terminal_access_failure(self):
|
|
err = RuntimeError(
|
|
"Provider 'opencode-zen' is set in config.yaml but no API key was "
|
|
"found. Set the OPENCODE-ZEN_API_KEY environment variable."
|
|
)
|
|
assert _is_summary_access_or_quota_error(err) is True
|
|
|
|
|
|
|
|
|
|
def test_400_out_of_extra_usage_aborts_instead_of_dropping_context(self):
|
|
"""Quota exhaustion preserves the original messages for a later retry."""
|
|
err = StubProviderError(
|
|
"Error code: 400 - {'error': {'message': 'out of extra usage'}}",
|
|
status_code=400,
|
|
)
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(
|
|
model="test",
|
|
quiet_mode=True,
|
|
protect_first_n=2,
|
|
protect_last_n=2,
|
|
abort_on_summary_failure=False,
|
|
)
|
|
msgs = self._msgs(12)
|
|
with patch("agent.context_compressor.call_llm", side_effect=err):
|
|
result = c.compress(msgs, current_tokens=999999, force=True)
|
|
|
|
assert result == msgs
|
|
assert c._last_summary_auth_failure is True
|
|
assert c._last_compress_aborted is True
|
|
assert c._last_summary_fallback_used is False
|
|
|
|
def test_missing_provider_api_key_preserves_original_messages(self):
|
|
"""A configured auxiliary provider without a visible key preserves context."""
|
|
err = RuntimeError(
|
|
"Provider 'opencode-zen' is set in config.yaml but no API key was "
|
|
"found. Set the OPENCODE-ZEN_API_KEY environment variable, or switch "
|
|
"to a different provider with hermes model."
|
|
)
|
|
with patch(
|
|
"agent.context_compressor.get_model_context_length", return_value=100000
|
|
):
|
|
c = ContextCompressor(
|
|
model="test",
|
|
quiet_mode=True,
|
|
protect_first_n=2,
|
|
protect_last_n=2,
|
|
abort_on_summary_failure=False,
|
|
)
|
|
msgs = self._msgs(12)
|
|
with patch("agent.context_compressor.call_llm", side_effect=err):
|
|
result = c.compress(msgs, current_tokens=999999, force=True)
|
|
|
|
assert result == msgs
|
|
assert c._last_summary_error == str(err)
|
|
assert c._last_summary_auth_failure is True
|
|
assert c._last_compress_aborted is True
|
|
assert c._last_summary_fallback_used is False
|
|
assert c._last_summary_dropped_count == 0
|
|
|
|
def test_402_quota_with_retry_uses_existing_fallback(self):
|
|
"""A reset-window quota remains transient instead of aborting compression."""
|
|
err = StubProviderError(
|
|
"quota exceeded, please retry after the window resets",
|
|
status_code=402,
|
|
)
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(
|
|
model="test",
|
|
quiet_mode=True,
|
|
protect_first_n=2,
|
|
protect_last_n=2,
|
|
abort_on_summary_failure=False,
|
|
)
|
|
msgs = self._msgs(12)
|
|
with patch("agent.context_compressor.call_llm", side_effect=err):
|
|
result = c.compress(msgs, current_tokens=999999, force=True)
|
|
|
|
assert result != msgs
|
|
assert c._last_summary_auth_failure is False
|
|
assert c._last_compress_aborted is False
|
|
assert c._last_summary_fallback_used is True
|
|
|
|
|
|
def test_403_also_flags_auth_failure(self):
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(model="test", quiet_mode=True)
|
|
with patch("agent.context_compressor.call_llm", side_effect=self._auth_err(403)):
|
|
c._generate_summary(self._msgs())
|
|
assert c._last_summary_auth_failure is True
|
|
|
|
|
|
|
|
def test_generate_summary_flags_network_failure(self):
|
|
"""A connection/network error on the summary call flags
|
|
_last_summary_network_failure (#29559)."""
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(model="test", quiet_mode=True)
|
|
with patch(
|
|
"agent.context_compressor.call_llm",
|
|
side_effect=ConnectionError("Connection error."),
|
|
):
|
|
result = c._generate_summary(self._msgs())
|
|
assert result is None
|
|
assert c._last_summary_network_failure is True
|
|
assert c._last_summary_auth_failure is False
|
|
|
|
|
|
|
|
|
|
class TestSummaryFallbackToMainModel:
|
|
"""When ``summary_model`` differs from the main model and the summary LLM
|
|
call fails, the compressor should retry once on the main model before
|
|
giving up — losing N turns of context is almost always worse than one
|
|
extra summary attempt. Covers both the fast-path (explicit
|
|
model-not-found errors) and the unknown-error best-effort retry."""
|
|
|
|
def _msgs(self):
|
|
return [
|
|
{"role": "user", "content": "do something"},
|
|
{"role": "assistant", "content": "ok"},
|
|
]
|
|
|
|
def test_model_not_found_404_falls_back_to_main_and_succeeds(self):
|
|
"""Classic misconfiguration: ``auxiliary.compression.model`` points at
|
|
a model the main provider doesn't serve → 404 → retry on main."""
|
|
mock_ok = MagicMock()
|
|
mock_ok.choices = [MagicMock()]
|
|
mock_ok.choices[0].message.content = "summary via main model"
|
|
|
|
err_404 = Exception("404 model_not_found: no such model")
|
|
err_404.status_code = 404
|
|
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(
|
|
model="main-model",
|
|
summary_model_override="broken-aux-model",
|
|
quiet_mode=True,
|
|
)
|
|
|
|
with patch(
|
|
"agent.context_compressor.call_llm",
|
|
side_effect=[err_404, mock_ok],
|
|
) as mock_call:
|
|
result = c._generate_summary(self._msgs())
|
|
|
|
assert mock_call.call_count == 2
|
|
# First call used the misconfigured aux model
|
|
assert mock_call.call_args_list[0].kwargs.get("model") == "broken-aux-model"
|
|
# Second call used the main model (no model kwarg → call_llm uses main)
|
|
assert "model" not in mock_call.call_args_list[1].kwargs
|
|
assert result is not None
|
|
assert "summary via main model" in result
|
|
# Aux-model failure is recorded even though retry succeeded — this is
|
|
# how callers (gateway /compress, CLI warning) know to tell the user
|
|
# their auxiliary.compression.model setting is broken.
|
|
assert c._last_aux_model_failure_model == "broken-aux-model"
|
|
assert c._last_aux_model_failure_error is not None
|
|
assert "404" in c._last_aux_model_failure_error
|
|
|
|
|
|
def test_no_fallback_when_summary_model_equals_main_model(self):
|
|
"""If the aux model IS the main model, there's nowhere to fall back
|
|
to — go straight to cooldown, don't loop retrying the same call."""
|
|
err = Exception("500 internal error")
|
|
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(
|
|
model="main-model",
|
|
summary_model_override="main-model", # same as main
|
|
quiet_mode=True,
|
|
)
|
|
|
|
with patch(
|
|
"agent.context_compressor.call_llm",
|
|
side_effect=err,
|
|
) as mock_call:
|
|
result = c._generate_summary(self._msgs())
|
|
|
|
# Only one attempt — retry gate blocks fallback when models match
|
|
assert mock_call.call_count == 1
|
|
assert result is None
|
|
# Not flagged as fallen back — the retry condition was never met
|
|
assert getattr(c, "_summary_model_fallen_back", False) is False
|
|
|
|
|
|
def test_json_decode_error_falls_back_to_main_and_succeeds(self):
|
|
"""JSONDecodeError from the OpenAI SDK's ``response.json()`` (raised
|
|
when a misconfigured proxy returns HTML/plain-text with
|
|
``Content-Type: application/json``) should trigger the same
|
|
retry-on-main path as 404/timeout. Issue #22244."""
|
|
import json as _json
|
|
|
|
mock_ok = MagicMock()
|
|
mock_ok.choices = [MagicMock()]
|
|
mock_ok.choices[0].message.content = "summary via main model"
|
|
|
|
# Simulate the SDK raising a raw JSONDecodeError with a realistic
|
|
# error message ("Expecting value: line X column Y char Z").
|
|
err_json = _json.JSONDecodeError(
|
|
"Expecting value", "<!DOCTYPE html><html>...</html>", 0
|
|
)
|
|
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(
|
|
model="main-model",
|
|
summary_model_override="aux-via-broken-proxy",
|
|
quiet_mode=True,
|
|
)
|
|
|
|
with patch(
|
|
"agent.context_compressor.call_llm",
|
|
side_effect=[err_json, mock_ok],
|
|
) as mock_call:
|
|
result = c._generate_summary(self._msgs())
|
|
|
|
assert mock_call.call_count == 2
|
|
assert mock_call.call_args_list[0].kwargs.get("model") == "aux-via-broken-proxy"
|
|
assert "model" not in mock_call.call_args_list[1].kwargs
|
|
assert result is not None
|
|
assert "summary via main model" in result
|
|
# Aux-model failure recorded so /usage / gateway warnings can surface it
|
|
assert c._last_aux_model_failure_model == "aux-via-broken-proxy"
|
|
assert c._last_aux_model_failure_error is not None
|
|
# The 220-char cap is shared with other fallback branches
|
|
assert len(c._last_aux_model_failure_error) <= 220
|
|
|
|
|
|
|
|
|
|
class TestStreamingClosedFallback:
|
|
"""httpcore / httpx streaming premature-close errors must be classified the
|
|
same as timeouts so the compressor retries on the main model instead of
|
|
entering a 60-second cooldown. Issue #18458.
|
|
|
|
``_is_connection_error`` is patched here because the test venv may not
|
|
have ``openai`` installed (the real function does ``from openai import ...``
|
|
inside its body). We test the *wiring* — that `_generate_summary` calls
|
|
``_is_connection_error`` and acts on its result — not the classifier itself
|
|
(that's covered in ``test_auxiliary_client.py::TestIsConnectionError``).
|
|
"""
|
|
|
|
def _msgs(self):
|
|
return [
|
|
{"role": "user", "content": "do something"},
|
|
{"role": "assistant", "content": "ok"},
|
|
]
|
|
|
|
def test_incomplete_chunked_read_falls_back_to_main(self):
|
|
"""``httpcore.RemoteProtocolError: incomplete chunked read`` triggers
|
|
the retry-on-main path when ``_is_connection_error`` returns True."""
|
|
mock_ok = MagicMock()
|
|
mock_ok.choices = [MagicMock()]
|
|
mock_ok.choices[0].message.content = "summary via main model"
|
|
|
|
err = Exception("RemoteProtocolError: incomplete chunked read")
|
|
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(
|
|
model="main-model",
|
|
summary_model_override="aux-stream-model",
|
|
quiet_mode=True,
|
|
)
|
|
|
|
with patch(
|
|
"agent.context_compressor.call_llm",
|
|
side_effect=[err, mock_ok],
|
|
) as mock_call, patch(
|
|
"agent.context_compressor._is_connection_error",
|
|
return_value=True,
|
|
):
|
|
result = c._generate_summary(self._msgs())
|
|
|
|
assert mock_call.call_count == 2
|
|
assert mock_call.call_args_list[0].kwargs.get("model") == "aux-stream-model"
|
|
assert "model" not in mock_call.call_args_list[1].kwargs
|
|
assert result is not None
|
|
assert "summary via main model" in result
|
|
|
|
|
|
def test_streaming_closed_on_main_uses_short_cooldown(self):
|
|
"""When already on the main model, a streaming-closed error should use
|
|
the 30s cooldown, not the default 60s — these errors are transient."""
|
|
err = Exception("RemoteProtocolError: response ended prematurely")
|
|
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(
|
|
model="main-model",
|
|
# No summary_model_override → no fallback path.
|
|
quiet_mode=True,
|
|
)
|
|
|
|
with patch(
|
|
"agent.context_compressor.call_llm",
|
|
side_effect=err,
|
|
), patch(
|
|
"agent.context_compressor._is_connection_error",
|
|
return_value=True,
|
|
), patch("agent.context_compressor.time.monotonic", return_value=1000.0):
|
|
result = c._generate_summary(self._msgs())
|
|
|
|
assert result is None
|
|
# Streaming-closed should use the 30s short cooldown.
|
|
assert c._summary_failure_cooldown_until == 1030.0
|
|
|
|
|
|
|
|
class TestAuxModelFallbackSurfacedToCallers:
|
|
"""When summary_model fails but retry-on-main succeeds, compress() must
|
|
expose the aux-model failure via _last_aux_model_failure_{model,error}
|
|
so gateway /compress and CLI callers can warn the user about their
|
|
broken auxiliary.compression.model config — silent recovery would hide
|
|
a misconfiguration only the user can fix."""
|
|
|
|
def _make_msgs(self):
|
|
return [
|
|
{"role": "system", "content": "sys"},
|
|
{"role": "user", "content": "msg 1"},
|
|
{"role": "assistant", "content": "msg 2"},
|
|
{"role": "user", "content": "msg 3"},
|
|
{"role": "assistant", "content": "msg 4"},
|
|
{"role": "user", "content": "msg 5"},
|
|
{"role": "assistant", "content": "msg 6"},
|
|
{"role": "user", "content": "msg 7"},
|
|
]
|
|
|
|
def test_compress_exposes_aux_failure_fields_after_successful_fallback(self):
|
|
mock_ok = MagicMock()
|
|
mock_ok.choices = [MagicMock()]
|
|
mock_ok.choices[0].message.content = "summary via main"
|
|
err_400 = Exception("400 provider rejected configured model")
|
|
err_400.status_code = 400
|
|
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(
|
|
model="main-model",
|
|
summary_model_override="broken-aux-model",
|
|
quiet_mode=True,
|
|
protect_first_n=2,
|
|
protect_last_n=2,
|
|
)
|
|
|
|
with patch(
|
|
"agent.context_compressor.call_llm",
|
|
side_effect=[err_400, mock_ok],
|
|
):
|
|
result = c.compress(self._make_msgs())
|
|
|
|
# Recovery succeeded → no fallback placeholder
|
|
assert c._last_summary_fallback_used is False
|
|
# But aux-model failure IS recorded for the gateway/CLI warning
|
|
assert c._last_aux_model_failure_model == "broken-aux-model"
|
|
assert c._last_aux_model_failure_error is not None
|
|
assert "400" in c._last_aux_model_failure_error
|
|
# Result is well-formed with a real summary, not a placeholder
|
|
assert any(
|
|
isinstance(m.get("content"), str) and "summary via main" in m["content"]
|
|
for m in result
|
|
)
|
|
|
|
def test_compress_clears_aux_failure_fields_at_start_of_next_call(self):
|
|
"""A subsequent successful compression must clear the aux-failure
|
|
fields so the warning doesn't persist forever."""
|
|
mock_ok = MagicMock()
|
|
mock_ok.choices = [MagicMock()]
|
|
mock_ok.choices[0].message.content = "summary via main"
|
|
err_400 = Exception("400 aux model busted")
|
|
err_400.status_code = 400
|
|
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(
|
|
model="main-model",
|
|
summary_model_override="broken-aux-model",
|
|
quiet_mode=True,
|
|
protect_first_n=2,
|
|
protect_last_n=2,
|
|
)
|
|
|
|
# Call 1: aux fails, retry-on-main succeeds
|
|
with patch(
|
|
"agent.context_compressor.call_llm",
|
|
side_effect=[err_400, mock_ok],
|
|
):
|
|
c.compress(self._make_msgs())
|
|
assert c._last_aux_model_failure_model == "broken-aux-model"
|
|
|
|
# Call 2: clean run on main (summary_model was cleared to "" after
|
|
# first fallback). Aux-failure fields MUST reset at compress() start
|
|
# so the old warning state doesn't leak into this call.
|
|
with patch(
|
|
"agent.context_compressor.call_llm",
|
|
return_value=mock_ok,
|
|
):
|
|
c.compress(self._make_msgs())
|
|
assert c._last_aux_model_failure_model is None
|
|
assert c._last_aux_model_failure_error is None
|
|
|
|
|
|
class TestSummaryFailureTrackingForGatewayWarning:
|
|
"""Default behavior (compression.abort_on_summary_failure=False):
|
|
summary-generation failure inserts a static fallback placeholder and
|
|
records dropped count + fallback flag so gateway hygiene & /compress
|
|
can surface a visible warning."""
|
|
|
|
def test_compress_records_fallback_and_dropped_count_on_summary_failure(self):
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2)
|
|
|
|
msgs = [
|
|
{"role": "system", "content": "sys"},
|
|
{"role": "user", "content": "msg 1"},
|
|
{"role": "assistant", "content": "msg 2"},
|
|
{"role": "user", "content": "msg 3"},
|
|
{"role": "assistant", "content": "msg 4"},
|
|
{"role": "user", "content": "msg 5"},
|
|
{"role": "assistant", "content": "msg 6"},
|
|
{"role": "user", "content": "msg 7"},
|
|
]
|
|
|
|
with patch("agent.context_compressor.call_llm", side_effect=Exception("404 model not found")):
|
|
result = c.compress(msgs)
|
|
|
|
assert c._last_summary_fallback_used is True
|
|
assert c._last_summary_dropped_count > 0
|
|
assert c._last_summary_error is not None
|
|
# Default mode: abort flag must NOT fire.
|
|
assert c._last_compress_aborted is False
|
|
assert any(
|
|
isinstance(m.get("content"), str) and "Summary generation was unavailable" in m["content"]
|
|
for m in result
|
|
)
|
|
|
|
def test_summary_failure_fallback_preserves_tool_paths_and_redacts_secret_context(self):
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=1, protect_last_n=1)
|
|
|
|
secret = "ghp_" + ("a" * 36)
|
|
msgs = [
|
|
{"role": "system", "content": "sys"},
|
|
{"role": "user", "content": f"Fix /tmp/project/app.py and never leak {secret}"},
|
|
{
|
|
"role": "assistant",
|
|
"content": "I will inspect it.",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call-1",
|
|
"function": {
|
|
"name": "read_file",
|
|
"arguments": '{"path":"/tmp/project/app.py"}',
|
|
},
|
|
}
|
|
],
|
|
},
|
|
{"role": "tool", "tool_call_id": "call-1", "content": f"read /tmp/project/app.py with token {secret}"},
|
|
{"role": "assistant", "content": "Found the bug in /tmp/project/app.py"},
|
|
{"role": "user", "content": "Patch it after this"},
|
|
{"role": "assistant", "content": "Ready to patch"},
|
|
{"role": "user", "content": "current live request should stay in tail"},
|
|
]
|
|
|
|
with patch("agent.context_compressor.call_llm", side_effect=Exception("timeout")):
|
|
result = c.compress(msgs)
|
|
|
|
fallback = next(m["content"] for m in result if "Summary generation was unavailable" in m.get("content", ""))
|
|
assert "Called tool(s): read_file" in fallback
|
|
assert "/tmp/project/app.py" in fallback
|
|
assert secret not in fallback
|
|
assert "ghp_" not in fallback
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestAbortOnSummaryFailure:
|
|
"""Opt-in behavior (compression.abort_on_summary_failure=True):
|
|
summary-generation failure ABORTS compression entirely — returns the
|
|
original messages unchanged and sets _last_compress_aborted=True so
|
|
gateway hygiene & /compress can surface a visible warning."""
|
|
|
|
def _make_msgs(self):
|
|
return [
|
|
{"role": "system", "content": "sys"},
|
|
{"role": "user", "content": "msg 1"},
|
|
{"role": "assistant", "content": "msg 2"},
|
|
{"role": "user", "content": "msg 3"},
|
|
{"role": "assistant", "content": "msg 4"},
|
|
{"role": "user", "content": "msg 5"},
|
|
{"role": "assistant", "content": "msg 6"},
|
|
{"role": "user", "content": "msg 7"},
|
|
]
|
|
|
|
def _make_compressor(self):
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
return ContextCompressor(
|
|
model="test",
|
|
quiet_mode=True,
|
|
protect_first_n=2,
|
|
protect_last_n=2,
|
|
abort_on_summary_failure=True,
|
|
)
|
|
|
|
def test_compress_aborts_and_preserves_messages_on_summary_failure(self):
|
|
c = self._make_compressor()
|
|
msgs = self._make_msgs()
|
|
with patch("agent.context_compressor.call_llm", side_effect=Exception("404 model not found")):
|
|
result = c.compress(msgs)
|
|
|
|
assert c._last_compress_aborted is True
|
|
assert c._last_summary_error is not None
|
|
# No fallback inserted, no messages dropped
|
|
assert c._last_summary_fallback_used is False
|
|
assert c._last_summary_dropped_count == 0
|
|
# Original messages preserved byte-for-byte.
|
|
assert result == msgs
|
|
# No "Summary generation was unavailable" placeholder leaked in.
|
|
assert not any(
|
|
isinstance(m.get("content"), str) and "Summary generation was unavailable" in m["content"]
|
|
for m in result
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_aux_fallback_clears_persisted_session_cooldown_before_retry(self, tmp_path):
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
db.create_session("s1", "cli")
|
|
db.record_compression_failure_cooldown("s1", time.time() + 999.0, "timeout")
|
|
|
|
c = self._make_compressor()
|
|
c.bind_session_state(db, "s1")
|
|
c.summary_model = "aux/model"
|
|
|
|
c._fallback_to_main_for_compression(Exception("provider down"), "failed")
|
|
|
|
assert c.summary_model == ""
|
|
assert c._summary_failure_cooldown_until == 0.0
|
|
assert db.get_compression_failure_cooldown("s1") is None
|
|
|
|
def test_success_clears_persisted_session_cooldown(self, tmp_path):
|
|
mock_response = MagicMock()
|
|
mock_response.choices = [MagicMock()]
|
|
mock_response.choices[0].message.content = "summary text"
|
|
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
db.create_session("s1", "cli")
|
|
db.record_compression_failure_cooldown("s1", time.time() + 999.0, "timeout")
|
|
|
|
c = self._make_compressor()
|
|
c.bind_session_state(db, "s1")
|
|
c._summary_failure_cooldown_until = 0.0
|
|
msgs = self._make_msgs()
|
|
|
|
with patch("agent.context_compressor.call_llm", return_value=mock_response) as mock_llm:
|
|
result = c.compress(msgs, current_tokens=999999)
|
|
|
|
mock_llm.assert_called()
|
|
assert c._last_compress_aborted is False
|
|
assert len(result) < len(msgs)
|
|
assert db.get_compression_failure_cooldown("s1") is None
|
|
|
|
|
|
|
|
class TestSummaryPrefixNormalization:
|
|
def test_legacy_prefix_is_replaced(self):
|
|
summary = ContextCompressor._with_summary_prefix("[CONTEXT SUMMARY]: did work")
|
|
assert summary == f"{SUMMARY_PREFIX}\ndid work"
|
|
|
|
def test_existing_new_prefix_is_not_duplicated(self):
|
|
summary = ContextCompressor._with_summary_prefix(f"{SUMMARY_PREFIX}\ndid work")
|
|
assert summary == f"{SUMMARY_PREFIX}\ndid work"
|
|
|
|
|
|
class TestCompressWithClient:
|
|
|
|
|
|
|
|
def test_sanitizer_matches_responses_call_id_when_id_differs(self, compressor):
|
|
msgs = [
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "fc_123",
|
|
"call_id": "call_123",
|
|
"response_item_id": "fc_123",
|
|
"type": "function",
|
|
"function": {"name": "search_files", "arguments": "{}"},
|
|
}
|
|
],
|
|
},
|
|
{"role": "tool", "tool_call_id": "call_123", "content": "result"},
|
|
]
|
|
|
|
sanitized = compressor._sanitize_tool_pairs(msgs)
|
|
|
|
assert [m.get("tool_call_id") for m in sanitized if m.get("role") == "tool"] == [
|
|
"call_123"
|
|
]
|
|
|
|
def test_user_role_summary_carries_end_marker(self):
|
|
"""When the summary lands as standalone role='user' (e.g. head ends
|
|
with assistant/tool), the message body must include the explicit
|
|
'--- END OF CONTEXT SUMMARY ---' marker. Without it, weak models
|
|
read the verbatim past user request quoted in the historical task
|
|
snapshot as
|
|
fresh input (#11475, #14521).
|
|
"""
|
|
mock_response = MagicMock()
|
|
mock_response.choices = [MagicMock()]
|
|
mock_response.choices[0].message.content = "summary text"
|
|
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2)
|
|
|
|
# head_last=assistant, tail_first=assistant (same shape as the
|
|
# existing consecutive-user test) → role resolves to "user".
|
|
msgs = [
|
|
{"role": "user", "content": "msg 0"},
|
|
{"role": "assistant", "content": "msg 1"},
|
|
{"role": "user", "content": "msg 2"},
|
|
{"role": "assistant", "content": "msg 3"},
|
|
{"role": "user", "content": "msg 4"},
|
|
{"role": "assistant", "content": "msg 5"},
|
|
{"role": "user", "content": "msg 6"},
|
|
{"role": "assistant", "content": "msg 7"},
|
|
]
|
|
with patch("agent.context_compressor.call_llm", return_value=mock_response):
|
|
result = c.compress(msgs)
|
|
|
|
summary_msg = next(
|
|
m for m in result if (m.get("content") or "").startswith(SUMMARY_PREFIX)
|
|
)
|
|
assert summary_msg["role"] == "user"
|
|
assert "END OF CONTEXT SUMMARY" in summary_msg["content"]
|
|
assert summary_msg["content"].rstrip().endswith(
|
|
"respond to the message below, not the summary above ---"
|
|
)
|
|
|
|
def test_assistant_role_summary_carries_end_marker(self):
|
|
"""When the summary lands as standalone role='assistant' (head ends
|
|
with user), the message body must include the explicit
|
|
'--- END OF CONTEXT SUMMARY ---' marker. Without it, models may
|
|
regurgitate the summary text as their own output (#33256).
|
|
"""
|
|
mock_client = MagicMock()
|
|
mock_response = MagicMock()
|
|
mock_response.choices = [MagicMock()]
|
|
mock_response.choices[0].message.content = "[CONTEXT SUMMARY]: stuff happened"
|
|
mock_client.chat.completions.create.return_value = mock_response
|
|
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2)
|
|
|
|
# head_last=user → summary_role="assistant" (same setup as
|
|
# test_summary_role_avoids_consecutive_user_when_head_ends_with_user).
|
|
# With min_tail=3, tail = last 3 messages (indices 5-7).
|
|
# head_last=user, tail_first=user → the assistant-role summary does
|
|
# not collide with either neighbor and should be inserted standalone.
|
|
msgs = [
|
|
{"role": "system", "content": "system prompt"},
|
|
{"role": "user", "content": "msg 1"},
|
|
{"role": "user", "content": "msg 2"}, # last head — user
|
|
{"role": "assistant", "content": "msg 3"},
|
|
{"role": "user", "content": "msg 4"},
|
|
{"role": "user", "content": "msg 5"},
|
|
{"role": "assistant", "content": "msg 6"},
|
|
{"role": "user", "content": "msg 7"},
|
|
]
|
|
with patch("agent.context_compressor.call_llm", return_value=mock_response):
|
|
result = c.compress(msgs)
|
|
|
|
summary_msg = next(
|
|
m for m in result if (m.get("content") or "").startswith(SUMMARY_PREFIX)
|
|
)
|
|
assert summary_msg["role"] == "assistant"
|
|
assert "END OF CONTEXT SUMMARY" in summary_msg["content"]
|
|
assert summary_msg["content"].rstrip().endswith(
|
|
"respond to the message below, not the summary above ---"
|
|
)
|
|
|
|
def test_summary_role_avoids_consecutive_user_messages(self):
|
|
"""Summary role should alternate with the last head message to avoid consecutive same-role messages."""
|
|
mock_client = MagicMock()
|
|
mock_response = MagicMock()
|
|
mock_response.choices = [MagicMock()]
|
|
mock_response.choices[0].message.content = "[CONTEXT SUMMARY]: stuff happened"
|
|
mock_client.chat.completions.create.return_value = mock_response
|
|
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2)
|
|
|
|
# Last head message (index 1) is "assistant" → summary should be "user".
|
|
# With min_tail=3, tail = last 3 messages (indices 5-7).
|
|
# head_last=assistant, tail_first=assistant → summary_role="user", no collision.
|
|
# Need 8 messages: min_for_compress = 2+3+1 = 6, must have > 6.
|
|
msgs = [
|
|
{"role": "user", "content": "msg 0"},
|
|
{"role": "assistant", "content": "msg 1"},
|
|
{"role": "user", "content": "msg 2"},
|
|
{"role": "assistant", "content": "msg 3"},
|
|
{"role": "user", "content": "msg 4"},
|
|
{"role": "assistant", "content": "msg 5"},
|
|
{"role": "user", "content": "msg 6"},
|
|
{"role": "assistant", "content": "msg 7"},
|
|
]
|
|
with patch("agent.context_compressor.call_llm", return_value=mock_response):
|
|
result = c.compress(msgs)
|
|
summary_msg = [
|
|
m for m in result if (m.get("content") or "").startswith(SUMMARY_PREFIX)
|
|
]
|
|
assert len(summary_msg) == 1
|
|
assert summary_msg[0]["role"] == "user"
|
|
|
|
|
|
|
|
def test_double_collision_merges_summary_into_tail(self):
|
|
"""When neither role avoids collision with both neighbors, the summary
|
|
should be merged into the first tail message rather than creating a
|
|
standalone message that breaks role alternation.
|
|
|
|
Common scenario: head ends with 'assistant', tail starts with 'user'.
|
|
summary='user' collides with tail, summary='assistant' collides with head.
|
|
"""
|
|
mock_response = MagicMock()
|
|
mock_response.choices = [MagicMock()]
|
|
mock_response.choices[0].message.content = "summary text"
|
|
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=3)
|
|
|
|
# Head: [system, user, assistant] → last head = assistant
|
|
# Tail: [user, assistant, user] → first tail = user
|
|
# summary_role="user" collides with tail, "assistant" collides with head → merge
|
|
# NOTE: protect_first_n=2 preserves 2 non-system messages in addition to
|
|
# the system prompt (always implicitly protected).
|
|
msgs = [
|
|
{"role": "system", "content": "system prompt"},
|
|
{"role": "user", "content": "msg 1"},
|
|
{"role": "assistant", "content": "msg 2"},
|
|
{"role": "user", "content": "msg 3"}, # compressed
|
|
{"role": "assistant", "content": "msg 4"}, # compressed
|
|
{"role": "user", "content": "msg 5"}, # compressed
|
|
{"role": "user", "content": "msg 6"}, # tail start
|
|
{"role": "assistant", "content": "msg 7"},
|
|
{"role": "user", "content": "msg 8"},
|
|
]
|
|
with patch("agent.context_compressor.call_llm", return_value=mock_response):
|
|
result = c.compress(msgs)
|
|
|
|
# Verify no consecutive user or assistant messages
|
|
for i in range(1, len(result)):
|
|
r1 = result[i - 1].get("role")
|
|
r2 = result[i].get("role")
|
|
if r1 in {"user", "assistant"} and r2 in {"user", "assistant"}:
|
|
assert r1 != r2, f"consecutive {r1} at indices {i-1},{i}"
|
|
|
|
# The summary text should be merged into the first tail message
|
|
first_tail = [m for m in result if "msg 6" in (m.get("content") or "")]
|
|
assert len(first_tail) == 1
|
|
assert "summary text" in first_tail[0]["content"]
|
|
|
|
|
|
def test_merge_into_tail_end_marker_is_last(self):
|
|
"""Regression for #56372: in a merge-into-tail summary, the END MARKER
|
|
must come AFTER the preserved prior tail content, not before it.
|
|
|
|
The old format was SUMMARY + END_MARKER + OLD_CONTENT, so the preserved
|
|
tail content landed after the marker and the model could read it as a
|
|
fresh message. The fix wraps old content in [PRIOR CONTEXT] delimiters
|
|
and always places the END MARKER last.
|
|
|
|
Mirrors test_double_collision_merges_summary_into_list_tail_content so
|
|
the merged tail message genuinely carries preserved content ("msg 6").
|
|
"""
|
|
from agent.context_compressor import _SUMMARY_END_MARKER
|
|
|
|
mock_response = MagicMock()
|
|
mock_response.choices = [MagicMock()]
|
|
mock_response.choices[0].message.content = "SUMMARY_BODY"
|
|
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=3)
|
|
|
|
msgs = [
|
|
{"role": "system", "content": "system prompt"},
|
|
{"role": "user", "content": "msg 1"},
|
|
{"role": "assistant", "content": "msg 2"},
|
|
{"role": "user", "content": "msg 3"},
|
|
{"role": "assistant", "content": "msg 4"},
|
|
{"role": "user", "content": "msg 5"},
|
|
{"role": "user", "content": [{"type": "text", "text": "PRESERVED_TAIL_CONTENT"}]},
|
|
{"role": "assistant", "content": "msg 7"},
|
|
{"role": "user", "content": "msg 8"},
|
|
]
|
|
|
|
with patch("agent.context_compressor.call_llm", return_value=mock_response):
|
|
result = c.compress(msgs)
|
|
|
|
merged = next(m for m in result if m.get(COMPRESSED_SUMMARY_METADATA_KEY))
|
|
content = merged["content"]
|
|
text = (
|
|
content if isinstance(content, str)
|
|
else " ".join(
|
|
b.get("text", "") for b in content if isinstance(b, dict)
|
|
)
|
|
)
|
|
end = _SUMMARY_END_MARKER.strip()
|
|
# All three fragments present.
|
|
assert "PRESERVED_TAIL_CONTENT" in text
|
|
assert "SUMMARY_BODY" in text
|
|
assert end in text
|
|
# Ordering invariant: prior content BEFORE summary BEFORE end marker,
|
|
# and the end marker is the very last fragment.
|
|
assert text.index("PRESERVED_TAIL_CONTENT") < text.index("SUMMARY_BODY")
|
|
assert text.index("SUMMARY_BODY") < text.index(end)
|
|
assert text.rstrip().endswith(end)
|
|
|
|
def test_merged_tail_summary_still_detected_and_stripped(self):
|
|
"""Regression for #56372 salvage: the merge-into-tail reorder moves the
|
|
summary prefix AFTER the [PRIOR CONTEXT] wrapper, so content-prefix
|
|
detection (_is_context_summary_content) and body extraction
|
|
(_strip_summary_prefix) must look past the delimiter. Otherwise a merged
|
|
summary is mistaken for a real user turn (breaking the last-real-user
|
|
anchor and carry-forward summary find) and the wrapper + stale tail
|
|
content leaks into the next summarizer prompt.
|
|
"""
|
|
from agent.context_compressor import (
|
|
SUMMARY_PREFIX,
|
|
_SUMMARY_END_MARKER,
|
|
_MERGED_PRIOR_CONTEXT_HEADER,
|
|
_MERGED_SUMMARY_DELIMITER,
|
|
)
|
|
|
|
merged = (
|
|
_MERGED_PRIOR_CONTEXT_HEADER + "\n"
|
|
"old tail content here\n\n"
|
|
+ _MERGED_SUMMARY_DELIMITER + "\n\n"
|
|
+ SUMMARY_PREFIX + "\nTHE_SUMMARY_BODY\n\n"
|
|
+ _SUMMARY_END_MARKER
|
|
)
|
|
|
|
# Detected as a summary despite the prefix not being at the start.
|
|
assert ContextCompressor._is_context_summary_content(merged) is True
|
|
# Stripping yields only the real summary body — no wrapper, no stale
|
|
# tail content, no prefix, no end marker.
|
|
body = ContextCompressor._strip_summary_prefix(merged)
|
|
assert body == "THE_SUMMARY_BODY"
|
|
|
|
# Standalone (non-merged) summaries still work unchanged.
|
|
standalone = SUMMARY_PREFIX + "\nSTANDALONE_BODY\n\n" + _SUMMARY_END_MARKER
|
|
assert ContextCompressor._is_context_summary_content(standalone) is True
|
|
assert ContextCompressor._strip_summary_prefix(standalone) == "STANDALONE_BODY"
|
|
|
|
|
|
|
|
|
|
|
|
class TestSummaryTargetRatio:
|
|
"""Verify that summary_target_ratio properly scales budgets with context window."""
|
|
|
|
|
|
|
|
|
|
def test_default_threshold_floored_at_75_percent_below_512k(self):
|
|
"""Sub-512K models get the 75% small-context threshold floor."""
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100_000):
|
|
c = ContextCompressor(model="test", quiet_mode=True)
|
|
_ = c.context_length
|
|
assert c.threshold_percent == 0.75
|
|
# 75% of 100K = 75K, above the 64K minimum floor
|
|
assert c.threshold_tokens == 75_000
|
|
|
|
|
|
|
|
|
|
|
|
def test_protect_first_n_0_preserves_only_system_prompt(self):
|
|
"""End-to-end: when protect_first_n=0, compression should treat only
|
|
the system prompt as head. All user/assistant messages between the
|
|
system prompt and the protected tail become summarization candidates.
|
|
|
|
This is the cleanest configuration for long-running rolling-compaction
|
|
sessions — no user/assistant turn gets pinned verbatim forever just
|
|
because it happened to be early in the session."""
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100_000):
|
|
c = ContextCompressor(
|
|
model="test",
|
|
quiet_mode=True,
|
|
protect_first_n=0,
|
|
protect_last_n=2,
|
|
)
|
|
msgs = (
|
|
[{"role": "system", "content": "System prompt"}]
|
|
+ [{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"}
|
|
for i in range(8)]
|
|
)
|
|
with patch("agent.context_compressor.call_llm", side_effect=RuntimeError("no provider")):
|
|
result = c.compress(msgs)
|
|
# System prompt (msg[0]) survives as head
|
|
assert result[0]["role"] == "system"
|
|
assert result[0]["content"].startswith("System prompt")
|
|
# The first user/assistant exchange (msg 0, msg 1) should NOT be pinned
|
|
# as head verbatim — those would have been summarized or absorbed.
|
|
# Under default protect_first_n=3, result[1..3] would be the literal
|
|
# "msg 0" / "msg 1" / "msg 2"; with protect_first_n=0 they aren't.
|
|
assert result[1].get("content") != "msg 0"
|
|
# Last 2 messages are tail-protected under protect_last_n=2
|
|
assert result[-1]["content"] == msgs[-1]["content"]
|
|
|
|
def test_protect_first_n_semantics_stable_without_system_prompt(self):
|
|
"""Regression: gateway /compress handler strips the system prompt
|
|
before calling compress(). protect_first_n must mean the same thing
|
|
in both paths — "N non-system head messages" — so configuring
|
|
protect_first_n=0 preserves NOTHING at the head regardless of whether
|
|
the system prompt is in the messages list.
|
|
|
|
Bug this covers: under the old semantics, protect_first_n counted
|
|
literally from messages[0]. In the gateway path (no system prompt)
|
|
that meant protect_first_n=1 would pin the first user turn of the
|
|
session forever — a user-reported complaint that a week-old
|
|
resolved question kept getting reinserted into every compaction
|
|
summary."""
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100_000):
|
|
c = ContextCompressor(
|
|
model="test",
|
|
quiet_mode=True,
|
|
protect_first_n=0,
|
|
protect_last_n=2,
|
|
)
|
|
# No system prompt — this is what the gateway passes to compress().
|
|
msgs = [
|
|
{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"}
|
|
for i in range(10)
|
|
]
|
|
head_size = c._protect_head_size(msgs)
|
|
# With no system prompt and protect_first_n=0 → head is empty.
|
|
# The first user message is NOT pinned as head.
|
|
assert head_size == 0
|
|
|
|
# And with protect_first_n=3 on the same no-system-prompt list →
|
|
# head size is 3 (the three earliest non-system messages).
|
|
c.protect_first_n = 3
|
|
assert c._protect_head_size(msgs) == 3
|
|
|
|
|
|
class TestTokenBudgetTailProtection:
|
|
"""Tests for token-budget-based tail protection (PR #6240).
|
|
|
|
The core change: tail protection is now based on a token budget rather
|
|
than a fixed message count. This prevents large tool outputs from
|
|
blocking compaction.
|
|
"""
|
|
|
|
@pytest.fixture()
|
|
def budget_compressor(self):
|
|
"""Compressor with known token budget for tail protection tests."""
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=200_000):
|
|
c = ContextCompressor(
|
|
model="test/model",
|
|
threshold_percent=0.50, # 100K threshold
|
|
protect_first_n=2,
|
|
protect_last_n=20,
|
|
quiet_mode=True,
|
|
)
|
|
return c
|
|
|
|
|
|
|
|
def test_tiny_budget_preserves_bounded_recent_turns(self, budget_compressor):
|
|
"""A token-exhausted tail must preserve more than just the latest ask.
|
|
|
|
Regression for #9413: the previous hard-coded 3-message floor could
|
|
leave the latest user message live while summarizing the assistant/tool
|
|
context immediately before it, which made the post-compression turn feel
|
|
like a fresh conversation.
|
|
"""
|
|
c = budget_compressor
|
|
c.tail_token_budget = 10
|
|
c.protect_last_n = 20
|
|
messages = [
|
|
{"role": "system", "content": "sys"},
|
|
{"role": "user", "content": "old start"},
|
|
{"role": "assistant", "content": "old ack"},
|
|
{"role": "user", "content": "middle work"},
|
|
{"role": "assistant", "content": "middle ack"},
|
|
{"role": "user", "content": "middle ask 2"},
|
|
{"role": "assistant", "content": "middle answer 2"},
|
|
{"role": "user", "content": "middle ask 3"},
|
|
{"role": "assistant", "content": "middle answer 3"},
|
|
{"role": "user", "content": "recent ask 1"},
|
|
{"role": "assistant", "content": "recent answer 1"},
|
|
{"role": "user", "content": "recent ask 2"},
|
|
{"role": "assistant", "content": "recent answer 2"},
|
|
{"role": "user", "content": "latest ask"},
|
|
]
|
|
|
|
cut = c._find_tail_cut_by_tokens(messages, head_end=1)
|
|
|
|
assert len(messages) - cut >= 8
|
|
assert messages[cut]["content"] == "middle answer 2"
|
|
assert messages[-1]["content"] == "latest ask"
|
|
|
|
|
|
def test_small_conversation_still_compresses(self, budget_compressor):
|
|
"""With the new min of 8 messages (head=2 + 3 + 1 guard + 2 middle),
|
|
a small but compressible conversation should still compress."""
|
|
c = budget_compressor
|
|
# 9 messages: head(2) + 4 middle + 3 tail = compressible
|
|
messages = []
|
|
for i in range(9):
|
|
role = "user" if i % 2 == 0 else "assistant"
|
|
messages.append({"role": role, "content": f"Message {i}"})
|
|
|
|
# Should not early-return (needs > protect_first_n + 3 + 1 = 6)
|
|
# Mock the summary generation to avoid real API call
|
|
with patch.object(c, "_generate_summary", return_value="Summary of conversation"):
|
|
result = c.compress(messages, current_tokens=90_000)
|
|
# Should have compressed (fewer messages than original)
|
|
assert len(result) < len(messages)
|
|
|
|
|
|
def test_prune_short_conv_protects_entire_tail(self, budget_compressor):
|
|
"""Regression guard for PR #17025.
|
|
|
|
When ``len(messages) <= protect_tail_count`` and a token budget is
|
|
also set, every message must be protected. The previous code used
|
|
``min(protect_tail_count, len(result) - 1)`` which capped the floor
|
|
one below the full length, leaving the oldest message eligible for
|
|
pruning.
|
|
"""
|
|
c = budget_compressor
|
|
# 4 messages, protect_tail_count=4 -- nothing should be pruned.
|
|
# Oldest message is a large tool result; on the buggy path it falls
|
|
# outside the protected window and gets summarized.
|
|
messages = [
|
|
{"role": "tool", "content": "x" * 5000, "tool_call_id": "c0"},
|
|
{"role": "assistant", "content": "ack"},
|
|
{"role": "user", "content": "recent"},
|
|
{"role": "assistant", "content": "reply"},
|
|
]
|
|
result, pruned = c._prune_old_tool_results(
|
|
messages,
|
|
protect_tail_count=4,
|
|
protect_tail_tokens=1_000_000, # budget large enough to protect all
|
|
)
|
|
assert pruned == 0
|
|
# Tool result at index 0 must be preserved verbatim
|
|
assert result[0]["content"] == "x" * 5000
|
|
|
|
|
|
def test_multimodal_message_accumulates_text_chars_not_block_count(self, budget_compressor):
|
|
"""_find_tail_cut_by_tokens must use text char count, not list length,
|
|
for multimodal content. Regression guard for #16087.
|
|
|
|
Setup: 6 messages, budget=80 (soft_ceiling=120). The multimodal message
|
|
at index 1 has 500 chars of text → 135 tokens (correct) or 10 tokens (bug).
|
|
|
|
Fixed path: walk stops at the multimodal (44+135=179 > 120), cut stays at 2,
|
|
tail = messages[2:] = 4 messages.
|
|
|
|
Bug path: walk counts only 10 tokens for the multimodal, exhausts to head_end,
|
|
the head_end safeguard forces cut = n - min_tail = 3, tail = only 3 messages.
|
|
"""
|
|
c = budget_compressor
|
|
# 500 chars → 500//4 + 10 = 135 tokens; len([text, image]) // 4 + 10 = 10 (bug)
|
|
big_text = "x" * 500
|
|
multimodal_content = [
|
|
{"type": "text", "text": big_text},
|
|
{"type": "image_url", "image_url": {"url": "https://example.com/img.jpg"}},
|
|
]
|
|
messages = [
|
|
{"role": "user", "content": "head1"}, # 0
|
|
{"role": "user", "content": multimodal_content}, # 1: BIG (index under test)
|
|
{"role": "assistant", "content": "tail1"}, # 2
|
|
{"role": "user", "content": "tail2"}, # 3
|
|
{"role": "assistant", "content": "tail3"}, # 4
|
|
{"role": "user", "content": "tail4"}, # 5
|
|
]
|
|
c.tail_token_budget = 80 # soft_ceiling = 120
|
|
head_end = 0
|
|
cut = c._find_tail_cut_by_tokens(messages, head_end)
|
|
# With the fix: cut=2, tail has 4 messages (soft_ceiling not exceeded by tail1-4).
|
|
# With the bug: head_end safeguard fires → cut = n - min_tail = 3, only 3 in tail.
|
|
assert len(messages) - cut >= 4, (
|
|
f"Expected ≥4 messages in tail (got {len(messages) - cut}, cut={cut}). "
|
|
"The multimodal message was underestimated — len(list) used instead of text chars."
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestUpdateModelBudgets:
|
|
"""Regression: update_model() must recalculate token budgets."""
|
|
|
|
def test_tail_budget_recalculated(self):
|
|
"""tail_token_budget must change after switching to a different context length."""
|
|
from unittest.mock import patch
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=200_000):
|
|
comp = ContextCompressor("model-a", threshold_percent=0.50, quiet_mode=True)
|
|
old_tail = comp.tail_token_budget
|
|
old_max_summary = comp.max_summary_tokens
|
|
|
|
comp.update_model("model-b", context_length=32_000)
|
|
assert comp.tail_token_budget != old_tail, "tail_token_budget should change"
|
|
assert comp.tail_token_budget < old_tail, "smaller context → smaller budget"
|
|
assert comp.max_summary_tokens != old_max_summary, "max_summary_tokens should change"
|
|
|
|
def test_budgets_proportional(self):
|
|
"""Budgets should be proportional to context_length after update."""
|
|
from unittest.mock import patch
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100_000):
|
|
comp = ContextCompressor("model-a", threshold_percent=0.50, quiet_mode=True)
|
|
comp.update_model("model-b", context_length=10_000)
|
|
assert comp.tail_token_budget == int(comp.threshold_tokens * comp.summary_target_ratio)
|
|
assert comp.max_summary_tokens == min(int(10_000 * 0.05), 4000)
|
|
|
|
|
|
class TestUpdateModelResetsCalibration:
|
|
"""#23767: update_model() must clear stale cross-call calibration state.
|
|
|
|
Old-model real-usage / defer baselines must not suppress a preflight
|
|
compression the new (smaller) model actually needs.
|
|
"""
|
|
|
|
def _comp(self):
|
|
from unittest.mock import patch
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=200_000):
|
|
return ContextCompressor("big-model", threshold_percent=0.50, quiet_mode=True)
|
|
|
|
def test_real_usage_state_cleared(self):
|
|
comp = self._comp()
|
|
# Simulate a large-model session that proved a prompt fit.
|
|
comp.last_prompt_tokens = 120_000
|
|
comp.last_real_prompt_tokens = 120_000
|
|
comp.last_rough_tokens_when_real_prompt_fit = 130_000
|
|
comp.last_compression_rough_tokens = 130_000
|
|
comp.awaiting_real_usage_after_compression = True
|
|
comp._ineffective_compression_count = 2
|
|
|
|
comp.update_model("small-model", context_length=65_536)
|
|
|
|
assert comp.last_prompt_tokens == 0
|
|
assert comp.last_real_prompt_tokens == 0
|
|
assert comp.last_rough_tokens_when_real_prompt_fit == 0
|
|
assert comp.last_compression_rough_tokens == 0
|
|
assert comp.awaiting_real_usage_after_compression is False
|
|
assert comp._ineffective_compression_count == 0
|
|
|
|
def test_defer_no_longer_suppresses_after_switch(self):
|
|
"""The exact #23767 failure: old model's 'it fit' must not defer
|
|
preflight on the new smaller model."""
|
|
comp = self._comp()
|
|
comp.last_real_prompt_tokens = 50_000
|
|
comp.last_rough_tokens_when_real_prompt_fit = 90_000
|
|
# Before switch, a modest rough growth would defer.
|
|
comp.threshold_tokens = 85_000
|
|
assert comp.should_defer_preflight_to_real_usage(93_000) is True
|
|
|
|
# After switching to a 65K model, the stale state is gone, so a rough
|
|
# estimate over the new threshold is NOT deferred — preflight will run.
|
|
comp.update_model("small-model", context_length=65_536)
|
|
assert comp.should_defer_preflight_to_real_usage(comp.threshold_tokens + 5_000) is False
|
|
|
|
|
|
|
|
|
|
|
|
class TestThresholdTokensCap:
|
|
"""Tests for the absolute token cap (compression.threshold_tokens).
|
|
|
|
The cap takes the lower of the ratio-based threshold and the absolute
|
|
count. It must survive model switches (update_model re-applies it)
|
|
and be clamped to the model's context length.
|
|
"""
|
|
|
|
|
|
|
|
def test_no_cap_uses_ratio_only(self):
|
|
"""Without a cap, the ratio-based threshold is used."""
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=1_000_000):
|
|
comp = ContextCompressor(
|
|
"model-a", threshold_percent=0.50, quiet_mode=True,
|
|
)
|
|
_ = comp.context_length
|
|
assert comp.threshold_tokens == 500_000
|
|
assert comp.threshold_tokens_cap is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_invalid_cap_treated_as_none(self):
|
|
"""Non-numeric, zero, or negative cap values are treated as None."""
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=1_000_000):
|
|
comp0 = ContextCompressor(
|
|
"model-a", threshold_percent=0.50, quiet_mode=True,
|
|
threshold_tokens_cap=0,
|
|
)
|
|
assert comp0.threshold_tokens_cap is None
|
|
assert comp0.threshold_tokens == 500_000
|
|
|
|
comp_neg = ContextCompressor(
|
|
"model-a", threshold_percent=0.50, quiet_mode=True,
|
|
threshold_tokens_cap=-100,
|
|
)
|
|
assert comp_neg.threshold_tokens_cap is None
|
|
|
|
comp_str = ContextCompressor(
|
|
"model-a", threshold_percent=0.50, quiet_mode=True,
|
|
threshold_tokens_cap="not-a-number",
|
|
)
|
|
assert comp_str.threshold_tokens_cap is None
|
|
|
|
def test_should_compress_fires_at_cap_below_ratio_threshold(self):
|
|
"""Behavioral: with a cap below the ratio-based threshold,
|
|
should_compress() fires once usage crosses the cap — even though
|
|
the percentage threshold has not been reached (first-fires-wins)."""
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=1_000_000):
|
|
comp = ContextCompressor(
|
|
"model-a", threshold_percent=0.50, quiet_mode=True,
|
|
threshold_tokens_cap=200_000,
|
|
)
|
|
# Ratio-based would be 500K; cap pulls the trigger down to 200K.
|
|
assert comp.should_compress(150_000) is False # below cap
|
|
assert comp.should_compress(200_000) is True # at cap (below 500K pct)
|
|
assert comp.should_compress(250_000) is True # above cap
|
|
|
|
def test_default_config_disabled_and_no_behavior_change(self):
|
|
"""DEFAULT_CONFIG ships threshold_tokens=None (disabled) and both
|
|
None and 0 leave the ratio-based trigger byte-identical."""
|
|
from hermes_cli.config import DEFAULT_CONFIG
|
|
assert DEFAULT_CONFIG["compression"]["threshold_tokens"] is None
|
|
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=1_000_000):
|
|
baseline = ContextCompressor(
|
|
"model-a", threshold_percent=0.50, quiet_mode=True,
|
|
)
|
|
comp_none = ContextCompressor(
|
|
"model-a", threshold_percent=0.50, quiet_mode=True,
|
|
threshold_tokens_cap=None,
|
|
)
|
|
comp_zero = ContextCompressor(
|
|
"model-a", threshold_percent=0.50, quiet_mode=True,
|
|
threshold_tokens_cap=0,
|
|
)
|
|
assert comp_none.threshold_tokens == baseline.threshold_tokens
|
|
assert comp_zero.threshold_tokens == baseline.threshold_tokens
|
|
# And after a model switch, still identical to baseline.
|
|
baseline.update_model("model-b", context_length=200_000)
|
|
comp_none.update_model("model-b", context_length=200_000)
|
|
comp_zero.update_model("model-b", context_length=200_000)
|
|
assert comp_none.threshold_tokens == baseline.threshold_tokens
|
|
assert comp_zero.threshold_tokens == baseline.threshold_tokens
|
|
|
|
|
|
|
|
class TestTruncateToolCallArgsJson:
|
|
"""Regression tests for #11762.
|
|
|
|
The previous implementation produced invalid JSON by slicing
|
|
``function.arguments`` mid-string, which caused non-retryable 400s from
|
|
strict providers (observed on MiniMax) and stuck long sessions in a
|
|
re-send loop. The helper here must always emit parseable JSON whose
|
|
shape matches the original — shrunken, not corrupted.
|
|
"""
|
|
|
|
def _helper(self):
|
|
from agent.context_compressor import _truncate_tool_call_args_json
|
|
return _truncate_tool_call_args_json
|
|
|
|
def test_shrunken_args_remain_valid_json(self):
|
|
import json as _json
|
|
shrink = self._helper()
|
|
original = _json.dumps({
|
|
"path": "~/.hermes/skills/shopping/browser-setup-notes.md",
|
|
"content": "# Shopping Browser Setup Notes\n\n" + "abc " * 400,
|
|
})
|
|
assert len(original) > 500
|
|
shrunk = shrink(original)
|
|
parsed = _json.loads(shrunk) # must not raise
|
|
assert parsed["path"] == "~/.hermes/skills/shopping/browser-setup-notes.md"
|
|
assert parsed["content"].endswith("...[truncated]")
|
|
assert len(shrunk) < len(original)
|
|
|
|
|
|
|
|
|
|
def test_non_string_leaves_preserved(self):
|
|
import json as _json
|
|
shrink = self._helper()
|
|
payload = _json.dumps({
|
|
"retries": 3,
|
|
"enabled": True,
|
|
"timeout": None,
|
|
"items": [1, 2, 3],
|
|
"note": "z" * 500,
|
|
})
|
|
parsed = _json.loads(shrink(payload))
|
|
assert parsed["retries"] == 3
|
|
assert parsed["enabled"] is True
|
|
assert parsed["timeout"] is None
|
|
assert parsed["items"] == [1, 2, 3]
|
|
assert parsed["note"].endswith("...[truncated]")
|
|
|
|
|
|
|
|
def test_pass3_emits_valid_json_for_downstream_provider(self):
|
|
"""End-to-end: Pass 3 must never produce the exact failure payload
|
|
that caused the 400 loop (unterminated string, missing brace)."""
|
|
import json as _json
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(
|
|
model="test/model",
|
|
threshold_percent=0.85,
|
|
protect_first_n=1,
|
|
protect_last_n=1,
|
|
quiet_mode=True,
|
|
)
|
|
huge_content = "# Shopping Browser Setup Notes\n\n## Overview\n" + "x " * 400
|
|
args_payload = _json.dumps({
|
|
"path": "~/.hermes/skills/shopping/browser-setup-notes.md",
|
|
"content": huge_content,
|
|
})
|
|
assert len(args_payload) > 500 # triggers the Pass-3 shrink
|
|
messages = [
|
|
{"role": "user", "content": "please write two files"},
|
|
{"role": "assistant", "content": None, "tool_calls": [
|
|
{"id": "call_1", "type": "function",
|
|
"function": {"name": "write_file", "arguments": args_payload}},
|
|
]},
|
|
{"role": "tool", "tool_call_id": "call_1",
|
|
"content": '{"bytes_written": 727}'},
|
|
{"role": "user", "content": "ok"},
|
|
{"role": "assistant", "content": "done"},
|
|
]
|
|
result, _ = c._prune_old_tool_results(messages, protect_tail_count=2)
|
|
shrunk = result[1]["tool_calls"][0]["function"]["arguments"]
|
|
# Must parse — otherwise downstream provider returns 400
|
|
parsed = _json.loads(shrunk)
|
|
assert parsed["path"] == "~/.hermes/skills/shopping/browser-setup-notes.md"
|
|
assert parsed["content"].endswith("...[truncated]")
|
|
|
|
|
|
class TestLazyContextResolution:
|
|
"""Verify that ContextCompressor defers get_model_context_length until
|
|
context_length is first accessed, so construction never blocks on network
|
|
I/O or blocks startup when the model metadata service is slow."""
|
|
|
|
|
|
def test_init_does_not_probe_when_not_quiet(self, caplog):
|
|
"""quiet_mode=False must ALSO stay non-blocking in __init__.
|
|
|
|
Regression for the lazy-init defect: the "Context compressor
|
|
initialized" log reads context_length/threshold_tokens/tail_token_budget,
|
|
so emitting it in __init__ forced the deferred get_model_context_length()
|
|
probe to run during construction whenever quiet_mode was False (the
|
|
interactive CLI path) — silently re-introducing the #32221 blocking that
|
|
the original PR set out to remove. The informative line must instead be
|
|
emitted once, on first context-length resolution.
|
|
"""
|
|
import logging
|
|
|
|
with patch(
|
|
"agent.context_compressor.get_model_context_length",
|
|
return_value=200_000,
|
|
) as mock_get:
|
|
c = ContextCompressor(model="test/model", quiet_mode=False)
|
|
# No probe, and no init log, at construction time.
|
|
mock_get.assert_not_called()
|
|
|
|
with caplog.at_level(logging.INFO, logger="agent.context_compressor"):
|
|
_ = c.context_length
|
|
mock_get.assert_called_once()
|
|
init_lines = [
|
|
r for r in caplog.records
|
|
if "Context compressor initialized" in r.getMessage()
|
|
]
|
|
assert len(init_lines) == 1, (
|
|
f"expected exactly one init log on first access, got {len(init_lines)}"
|
|
)
|
|
|
|
# Subsequent access must not re-probe or re-log.
|
|
with caplog.at_level(logging.INFO, logger="agent.context_compressor"):
|
|
_ = c.context_length
|
|
_ = c.threshold_tokens
|
|
mock_get.assert_called_once()
|
|
again = [
|
|
r for r in caplog.records
|
|
if "Context compressor initialized" in r.getMessage()
|
|
]
|
|
assert len(again) == 1, "init log fired more than once"
|
|
|
|
|
|
def test_config_context_length_skips_network_probe(self):
|
|
"""When config_context_length is provided, the resolver must use it
|
|
as the cached value and not make a network call."""
|
|
with patch(
|
|
"agent.context_compressor.get_model_context_length",
|
|
side_effect=lambda model, **kwargs: kwargs.get("config_context_length"),
|
|
) as mock_get:
|
|
c = ContextCompressor(
|
|
model="test/model",
|
|
quiet_mode=True,
|
|
config_context_length=200_000,
|
|
)
|
|
result = c.context_length
|
|
assert result == 200_000
|
|
|
|
|
|
class TestPreflightSentinelGuard:
|
|
"""Regression for #36718: the preflight token-display seed in
|
|
run_conversation must NOT overwrite the -1 sentinel that
|
|
compress_context() sets immediately after compression.
|
|
|
|
The old guard `_preflight_tokens > (last_prompt_tokens or 0)` evaluated
|
|
`(-1 or 0)` -> -1 (truthy), so any positive preflight estimate was > -1
|
|
and clobbered the sentinel with a schema-inflated rough count, re-firing
|
|
compression on the next turn. The fix treats any negative value as
|
|
"no real usage yet" and skips the seed.
|
|
"""
|
|
|
|
def _seed(self, last_prompt_tokens, preflight_tokens):
|
|
# Mirror the exact guard in agent/conversation_loop.py run_conversation.
|
|
_last = last_prompt_tokens
|
|
if _last >= 0 and preflight_tokens > _last:
|
|
return preflight_tokens # would overwrite
|
|
return last_prompt_tokens # preserved
|
|
|
|
def test_sentinel_preserved_after_compression(self, compressor):
|
|
compressor.last_prompt_tokens = -1
|
|
# A large schema-inflated preflight estimate must NOT overwrite -1.
|
|
result = self._seed(compressor.last_prompt_tokens, 250_000)
|
|
assert result == -1
|
|
|
|
def test_real_value_still_revises_upward(self, compressor):
|
|
compressor.last_prompt_tokens = 10_000
|
|
result = self._seed(compressor.last_prompt_tokens, 50_000)
|
|
assert result == 50_000
|
|
|
|
|
|
|
|
class TestTurnPairPreservation:
|
|
"""Causal Coupling guard (#22523): compaction must never orphan a user turn.
|
|
|
|
``_ensure_last_user_message_in_tail`` pulls the cut back to keep the last
|
|
user message in the tail (fixes #10896). But its final
|
|
``max(last_user_idx, head_end + 1)`` clamp pushes the cut *past* the user
|
|
when the user sits at ``head_end`` (the first compressible index) — the
|
|
only case where ``head_end + 1 > last_user_idx``. The user then lands in
|
|
the compressed region without its assistant reply; the summariser marks it
|
|
as a pending ask and the next session re-executes the completed task.
|
|
|
|
The guard detects that split and pushes the cut forward to ``pair_end`` so
|
|
the complete (user -> assistant [-> tool results]) pair is summarised as a
|
|
finished unit.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def compressor(self):
|
|
return ContextCompressor(
|
|
model="test/model",
|
|
threshold_percent=0.85,
|
|
protect_first_n=1,
|
|
protect_last_n=0,
|
|
quiet_mode=True,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# _find_turn_pair_end unit tests
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
# _ensure_last_user_message_in_tail unit tests
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
def test_user_in_compressed_region_pulled_back(self, compressor):
|
|
"""User in the middle (not at head_end) is pulled into the tail (#10896)."""
|
|
msgs = [
|
|
{"role": "user", "content": "head"}, # 0
|
|
{"role": "assistant", "content": "hi"}, # 1
|
|
{"role": "user", "content": "do thing"}, # 2 <- last user
|
|
{"role": "assistant", "content": "done"}, # 3
|
|
]
|
|
# head_end=0, so head_end+1=1 <= last_user_idx=2: the #10896 pullback
|
|
# applies and the user stays in the tail (no forward push).
|
|
result = compressor._ensure_last_user_message_in_tail(msgs, cut_idx=3, head_end=0)
|
|
assert result <= 2
|
|
|
|
def test_orphan_prevented_user_at_head_end(self, compressor):
|
|
"""Causal Coupling: user at head_end pushes the WHOLE pair into the summary.
|
|
|
|
This is the #22523 case: last_user_idx == head_end, so the clamp would
|
|
return head_end+1 and orphan the user. The guard instead pushes the
|
|
cut forward to pair_end so user + reply + tool results are summarised
|
|
together and the tail never starts with a dangling user ask.
|
|
"""
|
|
msgs = [
|
|
{"role": "user", "content": "first exchange"}, # 0 head
|
|
{"role": "user", "content": "THE ACTIVE ASK"}, # 1 = head_end, last user
|
|
{"role": "assistant", "content": "done"}, # 2 reply
|
|
{"role": "tool", "tool_call_id": "c1", "content": "toolout"}, # 3
|
|
{"role": "assistant", "content": "final reply"}, # 4
|
|
]
|
|
head_end = 1
|
|
result = compressor._ensure_last_user_message_in_tail(msgs, cut_idx=3, head_end=head_end)
|
|
# Whole pair (indices 1..3) lands in the compressed region; tail starts at 4.
|
|
assert result == 4
|
|
tail = msgs[result:]
|
|
assert tail and tail[0]["role"] == "assistant"
|
|
|
|
def test_no_orphan_after_full_compaction_cycle(self, compressor):
|
|
"""End-to-end: after _find_tail_cut_by_tokens, the tail never starts
|
|
with an unanswered user message."""
|
|
msgs = [
|
|
{"role": "user", "content": "initial"},
|
|
{"role": "assistant", "content": "ok"},
|
|
]
|
|
for i in range(5):
|
|
msgs.append({"role": "user", "content": f"step {i}"})
|
|
msgs.append({"role": "assistant", "content": f"done {i}"})
|
|
msgs.append({"role": "user", "content": "lights off please"})
|
|
msgs.append({"role": "assistant", "content": "lights are off"})
|
|
|
|
head_end = compressor.protect_first_n
|
|
cut = compressor._find_tail_cut_by_tokens(msgs, head_end)
|
|
tail = msgs[cut:]
|
|
|
|
if tail and tail[0].get("role") == "user":
|
|
assert len(tail) >= 2 and tail[1].get("role") == "assistant", (
|
|
f"Orphan user turn at tail start: {tail[0]['content']!r} — "
|
|
f"next role is {tail[1].get('role') if len(tail) > 1 else 'nothing'}"
|
|
)
|
|
|
|
|
|
class TestSanitizerStripsOrphanedToolCalls:
|
|
"""PR #51218 (salvaged from #51225): orphaned tool_calls are stripped from
|
|
assistant messages instead of having stub tool results inserted, avoiding
|
|
the call_id != id mismatch that let downstream repair_message_sequence drop
|
|
the stubs and re-expose orphans."""
|
|
|
|
def test_sanitizer_strips_orphaned_tool_calls(self, compressor):
|
|
"""Orphaned tool_calls (no matching tool result) are stripped from
|
|
assistant messages instead of having stubs inserted. #51218"""
|
|
msgs = [
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{"id": "tc_orphan", "function": {"name": "search", "arguments": "{}"}},
|
|
],
|
|
},
|
|
{"role": "user", "content": "never mind"},
|
|
]
|
|
|
|
sanitized = compressor._sanitize_tool_pairs(msgs)
|
|
|
|
# Orphaned tool_call should be stripped, not stub-inserted
|
|
asst = next(m for m in sanitized if m.get("role") == "assistant")
|
|
assert not asst.get("tool_calls"), "orphaned tool_calls should be stripped"
|
|
# No stub tool messages should be added
|
|
assert not any(m.get("role") == "tool" for m in sanitized)
|
|
# Empty assistant should get placeholder content
|
|
assert asst.get("content") == "(tool call removed)"
|
|
|
|
def test_sanitizer_strips_orphaned_keeps_valid(self, compressor):
|
|
"""When an assistant has both valid and orphaned tool_calls, only
|
|
the orphans are stripped. #51218"""
|
|
msgs = [
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{"id": "tc_valid", "function": {"name": "read_file", "arguments": "{}"}},
|
|
{"id": "tc_orphan", "function": {"name": "search", "arguments": "{}"}},
|
|
],
|
|
},
|
|
{"role": "tool", "tool_call_id": "tc_valid", "content": "file content"},
|
|
]
|
|
|
|
sanitized = compressor._sanitize_tool_pairs(msgs)
|
|
|
|
asst = next(m for m in sanitized if m.get("role") == "assistant")
|
|
assert len(asst["tool_calls"]) == 1
|
|
assert asst["tool_calls"][0]["id"] == "tc_valid"
|
|
# Valid tool result preserved
|
|
tool_msgs = [m for m in sanitized if m.get("role") == "tool"]
|
|
assert len(tool_msgs) == 1
|
|
assert tool_msgs[0]["tool_call_id"] == "tc_valid"
|
|
|
|
def test_sanitizer_strips_orphaned_preserves_text_content(self, compressor):
|
|
"""When an assistant has text content AND orphaned tool_calls,
|
|
the text is preserved and only tool_calls are stripped. #51218"""
|
|
msgs = [
|
|
{
|
|
"role": "assistant",
|
|
"content": "Let me search for that.",
|
|
"tool_calls": [
|
|
{"id": "tc_orphan", "function": {"name": "search", "arguments": "{}"}},
|
|
],
|
|
},
|
|
{"role": "user", "content": "thanks"},
|
|
]
|
|
|
|
sanitized = compressor._sanitize_tool_pairs(msgs)
|
|
|
|
asst = next(m for m in sanitized if m.get("role") == "assistant")
|
|
assert asst["content"] == "Let me search for that."
|
|
assert not asst.get("tool_calls")
|
|
# The placeholder must NOT overwrite existing text content.
|
|
assert asst["content"] != "(tool call removed)"
|
|
|
|
def test_sanitizer_strips_orphaned_with_call_id_mismatch(self, compressor):
|
|
"""Stubs with call_id != id used to be dropped by downstream
|
|
repair_message_sequence, re-exposing orphans. Stripping avoids
|
|
this entirely. #51218"""
|
|
msgs = [
|
|
{
|
|
"role": "assistant",
|
|
"content": "",
|
|
"tool_calls": [
|
|
{
|
|
"id": "fc_abc",
|
|
"call_id": "call_abc",
|
|
"function": {"name": "search", "arguments": "{}"},
|
|
},
|
|
],
|
|
},
|
|
# No tool result for call_abc — orphaned
|
|
{"role": "user", "content": "next"},
|
|
]
|
|
|
|
sanitized = compressor._sanitize_tool_pairs(msgs)
|
|
|
|
asst = next(m for m in sanitized if m.get("role") == "assistant")
|
|
assert not asst.get("tool_calls")
|
|
# No stub tool messages (which would have call_id != id mismatch)
|
|
|
|
|
|
class TestCooldownReentryAbort:
|
|
"""Regression: a second compress() call during the failure cooldown must
|
|
still abort when the original failure was a network/auth error.
|
|
|
|
Before the fix, compress() unconditionally reset _last_summary_network_failure
|
|
and _last_summary_auth_failure at the top of every call. When
|
|
_generate_summary() returned None from the cooldown early-return (without
|
|
re-setting the flags), the abort guard saw False and fell through to the
|
|
destructive static-fallback path — reproducing the data-loss scenario from
|
|
#29559 / #25585 that PR #51881 originally fixed.
|
|
"""
|
|
|
|
def _msgs(self, n=12):
|
|
return [
|
|
{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i}"}
|
|
for i in range(n)
|
|
]
|
|
|
|
def test_network_failure_cooldown_reentry_still_aborts(self):
|
|
"""ConnectionError → first compress aborts (PR #51881). Second
|
|
compress within the 30s cooldown must ALSO abort — not drop the
|
|
middle window via the static-fallback path."""
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(
|
|
model="test",
|
|
quiet_mode=True,
|
|
protect_first_n=2,
|
|
protect_last_n=2,
|
|
abort_on_summary_failure=False,
|
|
)
|
|
msgs = self._msgs(12)
|
|
|
|
with patch(
|
|
"agent.context_compressor.call_llm",
|
|
side_effect=ConnectionError("Connection error."),
|
|
):
|
|
first = c.compress(msgs, current_tokens=999999, force=True)
|
|
assert first == msgs
|
|
assert c._last_compress_aborted is True
|
|
assert c._last_summary_network_failure is True
|
|
|
|
second = c.compress(msgs, current_tokens=999999)
|
|
assert second == msgs, (
|
|
"Second compress during cooldown must abort (preserve messages), "
|
|
"not drop the middle window via static-fallback"
|
|
)
|
|
assert c._last_compress_aborted is True
|
|
assert c._last_summary_fallback_used is False
|
|
|
|
def test_auth_failure_cooldown_reentry_still_aborts(self):
|
|
"""Same re-entry hole for auth failures: a 401 sets the flag, cooldown
|
|
returns None, second compress must still abort."""
|
|
err = Exception("Error code: 401 - invalid api key")
|
|
err.status_code = 401
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(
|
|
model="test",
|
|
quiet_mode=True,
|
|
protect_first_n=2,
|
|
protect_last_n=2,
|
|
abort_on_summary_failure=False,
|
|
)
|
|
msgs = self._msgs(12)
|
|
|
|
with patch("agent.context_compressor.call_llm", side_effect=err):
|
|
first = c.compress(msgs, current_tokens=999999, force=True)
|
|
assert first == msgs
|
|
assert c._last_compress_aborted is True
|
|
assert c._last_summary_auth_failure is True
|
|
|
|
second = c.compress(msgs, current_tokens=999999)
|
|
assert second == msgs, (
|
|
"Second compress during cooldown must abort (preserve messages), "
|
|
"not drop the middle window via static-fallback"
|
|
)
|
|
assert c._last_compress_aborted is True
|
|
assert c._last_summary_fallback_used is False
|
|
|
|
|
|
class TestDoubleCompactionSummaryRole:
|
|
"""PR #52160 (salvaged from #52167): when only the system prompt is
|
|
protected, the summary must lead with role=user (Anthropic/Bedrock send
|
|
system as a separate param, so the summary is the first visible message)."""
|
|
|
|
def test_double_compaction_summary_must_be_user_when_only_system_protected(self):
|
|
"""After the first compression, protect_first_n decays to 0.
|
|
|
|
On the second compression the only protected head message is the
|
|
system prompt (role=system). The summary becomes the first
|
|
*visible* message in the API request because adapters like
|
|
Anthropic and Bedrock send the system prompt as a separate
|
|
``system`` parameter. The summary MUST be role=user or the
|
|
provider rejects with HTTP 400 (#52160).
|
|
"""
|
|
mock_response = MagicMock()
|
|
mock_response.choices = [MagicMock()]
|
|
mock_response.choices[0].message.content = "summary of earlier turns"
|
|
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(
|
|
model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2,
|
|
)
|
|
# Simulate second compression: protect_first_n decays to 0.
|
|
c.compression_count = 1
|
|
|
|
# compress_start will be 1 (system only), last_head_role = "system".
|
|
# Without the fix, summary_role would be "assistant".
|
|
msgs = [
|
|
{"role": "system", "content": "You are a helpful assistant."},
|
|
{"role": "user", "content": "msg 1"},
|
|
{"role": "assistant", "content": "msg 2"},
|
|
{"role": "user", "content": "msg 3"},
|
|
{"role": "assistant", "content": "msg 4"},
|
|
{"role": "user", "content": "msg 5"},
|
|
{"role": "assistant", "content": "msg 6"},
|
|
]
|
|
with patch("agent.context_compressor.call_llm", return_value=mock_response):
|
|
result = c.compress(msgs)
|
|
|
|
# The system message must still be at index 0.
|
|
assert result[0]["role"] == "system"
|
|
# The summary (first non-system message) must be role=user.
|
|
non_system = [m for m in result if m.get("role") != "system"]
|
|
assert non_system, "expected at least one non-system message"
|
|
assert non_system[0]["role"] == "user", (
|
|
f"first non-system message must be role=user for Anthropic "
|
|
f"compatibility, got role={non_system[0]['role']!r}"
|
|
)
|
|
|
|
def test_restart_handoff_without_system_still_starts_with_user(self):
|
|
"""When decayed head protection leaves no head, the visible transcript
|
|
must still begin with a user role for Anthropic/Bedrock compatibility.
|
|
"""
|
|
mock_response = MagicMock()
|
|
mock_response.choices = [MagicMock()]
|
|
mock_response.choices[0].message.content = "summary of resumed turns"
|
|
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(
|
|
model="test", quiet_mode=True, protect_first_n=3, protect_last_n=2,
|
|
)
|
|
|
|
msgs = [
|
|
{"role": "user", "content": f"{SUMMARY_PREFIX}\nold persisted summary"},
|
|
{"role": "assistant", "content": "handoff acknowledged"},
|
|
{"role": "user", "content": "new work after restart"},
|
|
{"role": "assistant", "content": "new answer after restart"},
|
|
{"role": "user", "content": "more new work after restart"},
|
|
{"role": "assistant", "content": "more new answer after restart"},
|
|
{"role": "user", "content": "tail request"},
|
|
{"role": "assistant", "content": "tail answer"},
|
|
]
|
|
with patch("agent.context_compressor.call_llm", return_value=mock_response):
|
|
result = c.compress(msgs)
|
|
|
|
assert result[0]["role"] == "user"
|
|
assert "summary of resumed turns" in (result[0].get("content") or "")
|
|
|
|
def test_double_compaction_user_tail_merges_into_tail(self):
|
|
"""When the summary is forced to role=user (system-only head) and
|
|
the first tail message is also user, the summary must merge into
|
|
the tail rather than flipping back to assistant (#52160).
|
|
"""
|
|
mock_response = MagicMock()
|
|
mock_response.choices = [MagicMock()]
|
|
mock_response.choices[0].message.content = "summary of earlier turns"
|
|
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
|
c = ContextCompressor(
|
|
model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2,
|
|
)
|
|
c.compression_count = 1 # decay protect_first_n
|
|
|
|
# tail starts with user → would collide with forced summary_role=user.
|
|
# The fix should merge into tail instead of flipping to assistant.
|
|
msgs = [
|
|
{"role": "system", "content": "You are a helpful assistant."},
|
|
{"role": "user", "content": "msg 1"},
|
|
{"role": "assistant", "content": "msg 2"},
|
|
{"role": "user", "content": "msg 3"},
|
|
{"role": "assistant", "content": "msg 4"},
|
|
{"role": "user", "content": "msg 5"}, # tail start (user)
|
|
{"role": "assistant", "content": "msg 6"},
|
|
{"role": "user", "content": "msg 7"},
|
|
]
|
|
with patch("agent.context_compressor.call_llm", return_value=mock_response):
|
|
result = c.compress(msgs)
|
|
|
|
# No standalone summary message should exist (merged into tail).
|
|
summary_msgs = [
|
|
m for m in result
|
|
if m.get("_compressed_summary") and "msg 5" not in (m.get("content") or "")
|
|
]
|
|
assert len(summary_msgs) == 0, (
|
|
"summary should be merged into tail, not standalone"
|
|
)
|
|
# The first non-system message must be role=user.
|
|
non_system = [m for m in result if m.get("role") != "system"]
|
|
assert non_system[0]["role"] == "user"
|
|
# The merged tail should contain the summary text.
|
|
assert any(
|
|
"summary of earlier turns" in (m.get("content") or "")
|
|
for m in result
|
|
)
|
|
|
|
|
|
class TestSummaryPromptBounding:
|
|
|
|
|
|
|
|
|
|
def test_iterative_update_path_is_bounded(self):
|
|
"""The iterative prompt (previous summary + new turns) must be bounded
|
|
too — a pathological rehydrated handoff must not blow up the prompt."""
|
|
mock_response = MagicMock()
|
|
mock_response.choices = [MagicMock()]
|
|
mock_response.choices[0].message.content = "updated summary"
|
|
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=272000):
|
|
c = ContextCompressor(model="test", quiet_mode=True)
|
|
cap = c._SUMMARY_INPUT_MAX_CHARS
|
|
c._previous_summary = "PREV_HEAD " + ("p" * (cap * 2)) + " PREV_TAIL"
|
|
|
|
messages = [
|
|
{"role": "user", "content": f"turn-{i}-" + ("x" * 6000)}
|
|
for i in range(80)
|
|
]
|
|
|
|
with patch("agent.context_compressor.call_llm", return_value=mock_response) as mock_call:
|
|
summary = c._generate_summary(messages)
|
|
|
|
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
|
|
assert summary.startswith(SUMMARY_PREFIX)
|
|
# previous summary block + new-turns block each capped, plus the
|
|
# fixed template: well under 3x the cap (unbounded would be ~800K).
|
|
assert len(prompt) < 2 * cap + 30_000
|
|
assert "PREV_HEAD" in prompt
|
|
assert "PREV_TAIL" in prompt
|
|
assert "summary input truncated" in prompt
|
|
|
|
def test_marker_does_not_collide_with_summary_classifier(self):
|
|
"""The omitted-middle marker must never make bounded content classify
|
|
as a compaction handoff (SUMMARY_PREFIX / merged-handoff patterns)."""
|
|
cap = ContextCompressor._SUMMARY_INPUT_MAX_CHARS
|
|
bounded = ContextCompressor._bound_summary_input("z" * (cap * 2))
|
|
assert "summary input truncated" in bounded
|
|
assert ContextCompressor.classify_summary_content(bounded) is None
|
|
# Marker alone (worst case: lands at the start of a message) is not a
|
|
# handoff prefix either.
|
|
marker_only = bounded[bounded.index("\n\n...[summary input truncated"):]
|
|
assert ContextCompressor.classify_summary_content(marker_only.lstrip()) is None
|
|
|
|
|
|
class TestMinTailUserMessages:
|
|
"""COMPRESS-01,02,07,08: N-user-message tail protection.
|
|
|
|
Tests the ``_ensure_last_n_user_messages_in_tail`` method and its
|
|
integration through ``_find_tail_cut_by_tokens``.
|
|
"""
|
|
|
|
|
|
def test_n3_tool_group_integrity(self):
|
|
"""COMPRESS-02: When the 3rd-to-last user message is preceded by
|
|
assistant(tool_calls) + tool results, the boundary is set at the
|
|
user message (a clean boundary). The preceding tool group stays
|
|
together — it is either entirely in the compressed region or
|
|
entirely in the tail, never split across the boundary."""
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=200_000):
|
|
c = ContextCompressor(
|
|
model="test/model",
|
|
threshold_percent=0.50,
|
|
protect_first_n=1,
|
|
quiet_mode=True,
|
|
min_tail_user_messages=3,
|
|
)
|
|
c.tail_token_budget = 200
|
|
messages = [
|
|
{"role": "user", "content": "start"},
|
|
{"role": "assistant", "content": None,
|
|
"tool_calls": [{"function": {"name": "read_file", "arguments": "{}"}}]},
|
|
{"role": "tool", "content": "result content",
|
|
"tool_call_id": "call_1"},
|
|
{"role": "user", "content": "user 3rd last"},
|
|
{"role": "assistant", "content": "reply 3rd last"},
|
|
{"role": "user", "content": "user 2nd last"},
|
|
{"role": "assistant", "content": "reply 2nd last"},
|
|
{"role": "user", "content": "user last"},
|
|
{"role": "assistant", "content": "reply last"},
|
|
]
|
|
head_end = c.protect_first_n
|
|
cut = c._find_tail_cut_by_tokens(messages, head_end)
|
|
compressed = messages[:cut]
|
|
tool_positions = [
|
|
i for i, m in enumerate(compressed)
|
|
if m.get("role") in ("assistant", "tool")
|
|
and (m.get("tool_calls") or m.get("tool_call_id"))
|
|
]
|
|
if len(tool_positions) >= 2:
|
|
assert tool_positions[-1] - tool_positions[0] == len(tool_positions) - 1, (
|
|
"Tool group must stay contiguous across the boundary"
|
|
)
|
|
tail_messages = messages[cut:]
|
|
tail_users = [m["content"] for m in tail_messages if m["role"] == "user"]
|
|
assert "user 3rd last" in tail_users
|
|
assert "user 2nd last" in tail_users
|
|
assert "user last" in tail_users
|
|
assert cut >= head_end + 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_default_is_behavior_preserving(self):
|
|
"""Default min_tail_user_messages=1 leaves the tail cut byte-identical
|
|
to the historical single-anchor pipeline.
|
|
|
|
A default of 3 was measured to CHANGE the cut on transcripts whose
|
|
tail budget covers only the last turn, so the default is gated to 1
|
|
(= the existing single last-user anchor) and N>1 is opt-in.
|
|
"""
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=200_000):
|
|
c_default = ContextCompressor(
|
|
model="test/model",
|
|
threshold_percent=0.50,
|
|
protect_first_n=2,
|
|
quiet_mode=True,
|
|
)
|
|
c_explicit = ContextCompressor(
|
|
model="test/model",
|
|
threshold_percent=0.50,
|
|
protect_first_n=2,
|
|
quiet_mode=True,
|
|
min_tail_user_messages=1,
|
|
)
|
|
assert c_default.min_tail_user_messages == 1
|
|
c_default.tail_token_budget = 200
|
|
c_explicit.tail_token_budget = 200
|
|
messages = [
|
|
{"role": "user", "content": "head msg"},
|
|
{"role": "assistant", "content": "head reply"},
|
|
]
|
|
for i in range(3):
|
|
messages.append({"role": "user", "content": f"user {i}"})
|
|
messages.append({"role": "assistant", "content": "X" * 4000})
|
|
messages.append({"role": "user", "content": "final user"})
|
|
messages.append({"role": "assistant", "content": "final reply"})
|
|
head_end = c_default.protect_first_n
|
|
assert (
|
|
c_default._find_tail_cut_by_tokens(messages, head_end)
|
|
== c_explicit._find_tail_cut_by_tokens(messages, head_end)
|
|
)
|
|
|
|
def test_blank_echo_does_not_count_toward_n(self):
|
|
"""A blank platform echo (empty user row) must not consume one of the
|
|
N slots — otherwise the guarantee silently degrades to N-1 real turns
|
|
(the #69291 bug class the single anchor already fixed)."""
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=200_000):
|
|
c = ContextCompressor(
|
|
model="test/model",
|
|
threshold_percent=0.50,
|
|
protect_first_n=1,
|
|
quiet_mode=True,
|
|
min_tail_user_messages=3,
|
|
)
|
|
messages = [
|
|
{"role": "user", "content": "head"}, # 0 (head)
|
|
{"role": "assistant", "content": "head reply"}, # 1
|
|
{"role": "user", "content": "real oldest"}, # 2 <- 3rd real user
|
|
{"role": "assistant", "content": "reply oldest"}, # 3
|
|
{"role": "user", "content": "real middle"}, # 4
|
|
{"role": "assistant", "content": "reply middle"}, # 5
|
|
{"role": "user", "content": ""}, # 6 blank echo
|
|
{"role": "assistant", "content": "reply to echo"}, # 7
|
|
{"role": "user", "content": " "}, # 8 whitespace echo
|
|
{"role": "assistant", "content": "another reply"}, # 9
|
|
{"role": "user", "content": "real latest"}, # 10
|
|
{"role": "assistant", "content": "final reply"}, # 11
|
|
]
|
|
head_end = c.protect_first_n # = 1
|
|
# cut_idx=10 → only "real latest" in tail; N=3 must walk back to
|
|
# index 2 ("real oldest"), NOT stop at a blank echo (6/8).
|
|
result = c._ensure_last_n_user_messages_in_tail(
|
|
messages, cut_idx=10, head_end=head_end, n=3
|
|
)
|
|
assert result == 2, (
|
|
f"3rd real user is at index 2, got cut {result} — blank echoes "
|
|
"must not count toward N"
|
|
)
|
|
tail_users = [
|
|
m["content"] for m in messages[result:]
|
|
if m["role"] == "user" and m["content"].strip()
|
|
]
|
|
assert {"real oldest", "real middle", "real latest"} <= set(tail_users)
|
|
|
|
|
|
|
|
def test_n_guarantee_wins_over_tail_token_budget_and_floor(self):
|
|
"""Interaction contract: the N-user guarantee WINS over both
|
|
tail_token_budget and _MAX_TAIL_MESSAGE_FLOOR.
|
|
|
|
The budget walk (and its bounded message floor) computes the initial
|
|
cut; the N-anchor then only ever pulls the cut BACKWARD (tail can
|
|
grow, never shrink), exactly like the existing single-user and
|
|
assistant anchors. So a tiny budget cannot roll real users 2..N into
|
|
the summary, and the floor remains a lower bound, not a cap, on the
|
|
anchored tail.
|
|
"""
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=200_000):
|
|
c = ContextCompressor(
|
|
model="test/model",
|
|
threshold_percent=0.50,
|
|
protect_first_n=1,
|
|
quiet_mode=True,
|
|
min_tail_user_messages=3,
|
|
)
|
|
# Budget covers roughly one bulky turn — without the N-anchor the
|
|
# cut lands after users 2..3.
|
|
c.tail_token_budget = 150
|
|
messages = [{"role": "user", "content": "head"},
|
|
{"role": "assistant", "content": "head reply"}]
|
|
for i in (3, 2, 1):
|
|
messages.append({"role": "user", "content": f"real {i}"})
|
|
messages.append({"role": "assistant", "content": "B" * 6000})
|
|
head_end = c.protect_first_n
|
|
cut = c._find_tail_cut_by_tokens(messages, head_end)
|
|
tail = messages[cut:]
|
|
tail_users = [m["content"] for m in tail if m["role"] == "user"]
|
|
assert {"real 1", "real 2", "real 3"} <= set(tail_users), (
|
|
f"N-guarantee must win over the token budget; tail users: {tail_users}"
|
|
)
|
|
# The anchored tail legitimately exceeds the budget (and the 8-message
|
|
# floor is a minimum, not a cap): the guarantee is the stronger
|
|
# invariant by design.
|
|
from agent.context_compressor import _estimate_msg_budget_tokens
|
|
accumulated = sum(_estimate_msg_budget_tokens(m) for m in tail)
|
|
assert accumulated > c.tail_token_budget
|
|
|
|
|
|
|
|
class TestContextLengthSetterCoherence:
|
|
"""The context_length setter must (a) not wipe runtime corrections on
|
|
no-op re-assignment of the same window (codex app-server usage callback
|
|
re-reports it every response), and (b) re-apply the small-context
|
|
threshold floor for a genuinely new window so percent and tokens derive
|
|
from the same window."""
|
|
|
|
def test_same_value_reassignment_preserves_threshold_override(self):
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=200_000):
|
|
c = ContextCompressor(model="test", quiet_mode=True)
|
|
_ = c.context_length
|
|
# Runtime correction (aux-context threshold sync pattern).
|
|
c.threshold_tokens = 42_000
|
|
c.tail_token_budget = 8_400
|
|
# Codex usage callback re-reports the same window every response.
|
|
c.context_length = 200_000
|
|
assert c.threshold_tokens == 42_000
|
|
assert c.tail_token_budget == 8_400
|
|
|
|
def test_new_value_assignment_refloors_and_invalidates(self):
|
|
with patch("agent.context_compressor.get_model_context_length", return_value=1_000_000):
|
|
c = ContextCompressor(model="test", quiet_mode=True)
|
|
_ = c.context_length
|
|
assert c.threshold_percent == 0.50 # 1M >= 512K: configured value
|
|
# Switch to a small window via direct assignment (codex path).
|
|
c.context_length = 200_000
|
|
# Floor re-applied for the new window...
|
|
assert c.threshold_percent == 0.75
|
|
# ...and budgets recompute from the same window+percent.
|
|
assert c.threshold_tokens == 150_000
|
|
|