Files
hermes-agent/tests/agent/test_compressor_truncated_summary_guard.py
kshitijk4poor a184cc994a test(agent): pin _previous_summary is None after a rejected refusal summary
tests/agent/test_compressor_truncated_summary_guard.py:197 asserted
`_previous_summary is None or refusal not in (_previous_summary or "")`. The
disjunction would still pass if the compressor committed ANY checkpoint
(e.g. a rewritten/derived one) after a refusal; the invariant this test
exists to protect is that a rejected refusal leaves no checkpoint at all,
exactly as the sibling provider-refusal-field test already asserts (L218).

Red with the refusal raise in _call_summary_llm defeated: compress() commits
the refusal as `_previous_summary` (is None -> False); green at head
(25 passed).

Gate finding: B.2c (weak disjunctive assertion).
2026-09-22 13:38:25 +05:30

305 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Truncated compaction summaries must never become checkpoints.
Port of earendil-works/pi#7048 (commit 97fa14e39): a summarization response
whose ``finish_reason == "length"`` contains PARTIAL text — the generation
stopped on the output-token cap mid-summary. Persisting it as the compaction
checkpoint silently truncates the conversation's memory and feeds the cut-off
text back into every subsequent iterative-update prompt.
Covers all three compressor summarization sites:
1. ``_generate_summary`` (main batch summary) — length stop raises, falls
back to main model once, then ABORTS compression preserving messages.
2. ``_micro_summarize_one`` (micro-compact rolling summary) — length stop
discards the partial merge (returns None) so the exchange stays
unabsorbed.
(The former third site, ``_build_chunk_digests``, was removed on main by
#96603 — lean digests now ride the single ``_generate_summary`` request, so
its guard is covered by site 1.)
"""
from unittest.mock import MagicMock, patch
import pytest
from agent.context_compressor import (
ContextCompressor,
_is_summary_refusal,
_response_finish_reason,
)
def _mock_response(content="a perfectly fine summary", finish_reason="stop"):
resp = MagicMock()
choice = MagicMock()
choice.message.content = content
choice.finish_reason = finish_reason
resp.choices = [choice]
return resp
def _msgs(n=12):
return [
{"role": "user" if i % 2 == 0 else "assistant", "content": f"msg {i} " + "x" * 50}
for i in range(n)
]
class TestResponseFinishReason:
def test_object_shaped(self):
assert _response_finish_reason(_mock_response(finish_reason="length")) == "length"
assert _response_finish_reason(_mock_response(finish_reason="stop")) == "stop"
def test_dict_shaped(self):
resp = {"choices": [{"message": {"content": "x"}, "finish_reason": "LENGTH"}]}
assert _response_finish_reason(resp) == "length"
def test_missing_field_is_empty(self):
assert _response_finish_reason({"choices": [{"message": {"content": "x"}}]}) == ""
assert _response_finish_reason({"choices": []}) == ""
assert _response_finish_reason(None) == ""
class TestSummaryRefusalGuard:
@pytest.mark.parametrize(
"content",
[
"I can't produce this summary as requested. The instructions conflict with my operating rules.",
"Sorry, I am unable to create a context checkpoint for this conversation.",
"I'm sorry, but I can't provide the requested summary.",
"I’m sorry, but I can’t provide the requested summary.",
"I apologize, but I cannot create a summary of this conversation.",
"As an AI, I cannot provide the requested context checkpoint.",
"I can not provide a summary of this conversation.",
"I can't summarise this conversation for you.",
],
)
def test_rejects_refusal_body(self, content):
assert _is_summary_refusal(content) is True
def test_accepts_structured_summary_that_records_a_refusal(self):
summary = "## Goal\nPreserve the user's task.\n\n## Completed Actions\n1. Recorded that a provider refused an earlier request."
assert _is_summary_refusal(summary) is False
def test_narrowing_and_heading_exemption(self):
# Narrow by design: a refusal that never mentions the summary/checkpoint is not ours to judge.
assert _is_summary_refusal("I can't help with that request.") is False
# A hedging preamble in front of a real templated summary is not a refusal.
preamble = (
"I cannot see the earlier turns, but here is the summary:\n"
"## Goal\nFinish the task.\n\n## Completed Actions\n1. Did X."
)
assert _is_summary_refusal(preamble) is False
class TestGenerateSummaryTruncationGuard:
def test_length_stop_is_rejected_and_aborts(self):
"""A length-stopped summary must not become a checkpoint; with no
distinct aux model to fall back from, compression ABORTS and the
session is preserved unchanged."""
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 = _msgs()
with patch(
"agent.context_compressor.call_llm",
return_value=_mock_response("partial summary that got cut o", "length"),
):
result = c.compress(msgs, current_tokens=999999, force=True)
assert result == msgs
assert c._last_summary_truncated_failure is True
assert c._last_compress_aborted is True
assert c._last_summary_fallback_used is False
# The partial text must never be stored for iterative updates.
assert c._previous_summary is None or "cut o" not in (c._previous_summary or "")
def test_length_stop_falls_back_to_main_model_once(self):
"""With a distinct aux summary model, a length stop retries once on
the main model (which may have a larger output budget) and succeeds."""
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(
model="main-model",
summary_model_override="small-aux-model",
quiet_mode=True,
)
truncated = _mock_response("partial...", "length")
ok = _mock_response("full summary via main model", "stop")
with patch(
"agent.context_compressor.call_llm",
side_effect=[truncated, ok],
) as mock_call:
result = c._generate_summary(_msgs(2))
assert mock_call.call_count == 2
assert result is not None
assert "full summary via main model" in result
assert c._last_summary_truncated_failure is False
def test_repeated_truncation_escalates_cooldown_across_turns(self):
"""#69637: a later turn (e.g. an async delegation completion arriving after the cooldown lapsed)
must not re-arm a fresh flat 30s attempt. Consecutive length-stopped summaries walk the durable
60s -> 300s -> 900s ladder, one LLM call per turn, and the transcript is preserved every time."""
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)
clock = [1000.0]
recorded = []
with (
patch("agent.context_compressor.time.monotonic", side_effect=lambda: clock[0]),
patch("agent.context_compressor.call_llm", return_value=_mock_response("partial...", "length")) as call,
):
for _turn in range(4):
msgs = _msgs()
assert c.compress(msgs, current_tokens=999999) == msgs
cooldown = c._summary_failure_cooldown_until - clock[0]
recorded.append(round(cooldown))
clock[0] += cooldown + 1 # the next turn arrives right after the cooldown lapses
assert recorded == [60, 300, 900, 900]
assert call.call_count == 4
# Truncation keeps its own streak: the timeout ladder (which arms the deterministic stall
# fallback via _prior_timeout_failures) is untouched.
assert c._consecutive_timeout_failures == 0
assert c._consecutive_truncation_failures == 4
def test_other_transient_failures_keep_short_cooldown(self):
"""Control: empty-content stays on the flat 30s rung and does not bump the truncation streak."""
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)
clock = [1000.0]
with (
patch("agent.context_compressor.time.monotonic", side_effect=lambda: clock[0]),
patch("agent.context_compressor.call_llm", side_effect=RuntimeError("LLM returned empty content")),
):
for _turn in range(2):
c.compress(_msgs(), current_tokens=999999)
assert round(c._summary_failure_cooldown_until - clock[0]) == 30
clock[0] += 31
assert c._consecutive_truncation_failures == 0
def test_stop_finish_reason_still_succeeds(self):
"""Control: a normal stop-terminated summary is accepted unchanged."""
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",
return_value=_mock_response("complete summary", "stop"),
):
result = c._generate_summary(_msgs(2))
assert result is not None
assert "complete summary" in result
def test_refusal_body_is_rejected_and_never_becomes_previous_summary(self):
"""A stop-terminated refusal is not a usable compaction checkpoint."""
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 = _msgs()
refusal = "I can't produce this summary as requested. The instructions conflict with my operating rules."
with patch("agent.context_compressor.call_llm", return_value=_mock_response(refusal, "stop")):
result = c.compress(msgs, current_tokens=999999, force=True)
assert result == msgs
assert c._last_summary_empty_content_failure is True
assert c._last_compress_aborted is True
assert c._previous_summary is None
def test_provider_refusal_field_is_rejected_even_with_summary_shaped_content(self):
"""An explicit ``message.refusal`` wins over plausible-looking content (#118406)."""
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 = _msgs()
filler = "## Goal\nContinue the task.\n\n## Completed Actions\n1. Nothing yet."
response = _mock_response(filler, "stop")
response.choices[0].message.refusal = "policy refusal"
with patch("agent.context_compressor.call_llm", return_value=response):
result = c.compress(msgs, current_tokens=999999, force=True)
assert result == msgs
assert c._last_summary_empty_content_failure is True
assert c._last_compress_aborted is True
assert "refusal content" in (c._last_summary_error or "")
assert c._previous_summary is None
def test_missing_finish_reason_still_succeeds(self):
"""Providers that omit finish_reason entirely must not be rejected."""
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test", quiet_mode=True)
resp = {"choices": [{"message": {"content": "complete summary"}}]}
with patch("agent.context_compressor.call_llm", return_value=resp):
result = c._generate_summary(_msgs(2))
assert result is not None
assert "complete summary" in result
def test_successful_summary_clears_truncated_flag(self):
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test", quiet_mode=True)
c._last_summary_truncated_failure = True
c._summary_failure_cooldown_until = 0
with patch(
"agent.context_compressor.call_llm",
return_value=_mock_response("fine", "stop"),
):
result = c._generate_summary(_msgs(2))
assert result is not None
assert c._last_summary_truncated_failure is False
class TestMicroSummarizeTruncationGuard:
def test_length_stop_discards_partial_merge(self):
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test", quiet_mode=True)
c._micro_compact_rolling_summary = "existing rolling summary"
with patch(
"agent.auxiliary_client.call_llm",
return_value=_mock_response("partial merge tex", "length"),
):
result = c._micro_summarize_one("user: hi\nassistant: hello")
assert result is None
def test_stop_finish_reason_merges(self):
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(model="test", quiet_mode=True)
c._micro_compact_rolling_summary = "existing"
with patch(
"agent.auxiliary_client.call_llm",
return_value=_mock_response("merged summary", "stop"),
):
result = c._micro_summarize_one("user: hi\nassistant: hello")
assert result == "merged summary"
def test_refusal_leaves_exchange_and_cursor_unchanged(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=2,
config_context_length=40960,
)
c._micro_compact_enabled = True
messages = _msgs()
assert c._next_exchange(messages) is not None
cursor_before = c._micro_compact_cursor
refusal = "I’m sorry, but I can’t provide the requested summary."
response_content = f"<think>Check the applicable policy.</think>\n{refusal}"
with patch(
"agent.auxiliary_client.call_llm",
return_value=_mock_response(response_content, "stop"),
):
result = c._micro_compact(list(messages))
assert result == messages
assert c._micro_compact_cursor == cursor_before
assert c._micro_compact_rolling_summary == ""
assert not any(refusal in str(message.get("content")) for message in result)