hermes-agent/tests/agent/test_summary_role_template_alternation.py
Matt Ezell 7965462d6c fix(compression): choose summary role by template-visible alternation
The compaction summary's role was selected against the LITERAL
neighbouring messages (compressed[-1] / tail_messages[0]). Mistral-family
chat templates (Devstral, Mistral Small 3.x, Magistral) enforce
user/assistant alternation but exempt the tool flow (tool results and
assistant messages carrying tool_calls) from the check, so a protected
head ending [user, assistant(tool_calls), tool] pinned the summary to
role="user" while the last role the template counts is "user": the
backend rejects the whole request with a Jinja alternation error
(HTTP 500). The summary persists in the stored conversation, every
retry replays the identical poisoned history, and the session is
permanently unrecoverable. Fires on EVERY compaction against a
Mistral-strict backend, captured byte-exact via a tee-proxy in front of
a llama.cpp/llama-swap Devstral deployment.

Fix: compute both neighbour roles through _template_visible_role(),
which skips template-exempt messages. The #52160 (Anthropic user-first)
and #58753 (zero-user-turn) forced-user guards are preserved; their
forced shapes (summary-user followed only by exempt messages) are
alternation-safe. When the visible head ends "assistant" and the
visible tail opens "user", no standalone role can alternate and the
existing merge-into-tail fallback now correctly fires (the literal
logic emitted a standalone user summary there: a second poisoning
shape).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGN45sMMbwM8cW9T9ga4ou
2026-07-30 21:07:36 +05:30

240 lines
9.5 KiB
Python

"""Regression coverage: the compaction summary role must alternate against
TEMPLATE-VISIBLE neighbours, not literal list neighbours.
Mistral-family chat templates (Devstral, Mistral Small 3.x, Magistral)
enforce user/assistant alternation at render time but exempt the tool flow
from the check: ``tool`` results and assistant messages carrying
``tool_calls`` are skipped. The summary-role selection previously keyed off
the LITERAL last head message (``compressed[-1]``), producing this captured
failure (Hermes Desktop v0.19.0 against llama.cpp-served Devstral):
[0] system
[1] user <- original first user turn, protected
[2] assistant (tool_calls, exempt)
[3] tool (exempt)
[4] user <- COMPACTION SUMMARY, pinned to "user" because the
literal previous role was "tool"
[5..] assistant(tool_calls)/tool pairs
The template counts only [1] and [4]: user -> user, and the backend rejects
the ENTIRE request with a Jinja alternation error (HTTP 500):
"After the optional system message, conversation roles must alternate
user and assistant roles except for tool calls and results"
Because the summary persists in the stored conversation, every retry
replays the same poisoned history: the session is permanently broken, on
EVERY compaction, overflow or not.
These tests pin the fix: neighbour roles for summary-role selection are
computed by ``_template_visible_role`` (skipping the exempt tool flow), and
the assembled output always satisfies the Mistral alternation check. The
existing #52160 / #58753 forced-user guards must keep winning: their forced
shapes (summary-user followed only by exempt messages) are alternation-safe.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from agent.context_compressor import (
COMPRESSED_SUMMARY_METADATA_KEY,
SUMMARY_PREFIX,
_template_visible_role,
)
@pytest.fixture()
def compressor():
from agent.context_compressor import ContextCompressor
with patch(
"agent.context_compressor.get_model_context_length",
return_value=100_000,
):
c = ContextCompressor(
model="test/model",
threshold_percent=0.50,
protect_first_n=3,
protect_last_n=20,
quiet_mode=True,
)
c.tail_token_budget = 40
return c
def _tool_turns(start: int, n: int, payload: str = "x" * 300) -> list[dict]:
out: list[dict] = []
for i in range(start, start + n):
out.append(
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": f"c{i}",
"function": {"name": "terminal", "arguments": "{}"},
}
],
}
)
out.append({"role": "tool", "content": payload, "tool_call_id": f"c{i}"})
return out
def _summary_rows(messages: list[dict]) -> list[dict]:
return [
m
for m in messages
if isinstance(m, dict) and m.get(COMPRESSED_SUMMARY_METADATA_KEY)
]
def _mistral_alternation_ok(messages: list[dict]) -> bool:
"""Replay the Mistral template's pre-flight alternation check: count
only user and assistant-without-tool_calls messages; the counted
sequence must go user, assistant, user, assistant, ..."""
expect = "user"
for m in messages:
role = _template_visible_role(m)
if role in (None, "system"):
continue
if role != expect:
return False
expect = "assistant" if expect == "user" else "user"
return True
class TestTemplateVisibleRoleHelper:
def test_tool_flow_is_exempt(self):
assert _template_visible_role({"role": "tool", "content": "r"}) is None
assert (
_template_visible_role(
{"role": "assistant", "content": "", "tool_calls": [{"id": "c"}]}
)
is None
)
def test_counted_roles_pass_through(self):
assert _template_visible_role({"role": "user", "content": "q"}) == "user"
assert (
_template_visible_role({"role": "assistant", "content": "a"})
== "assistant"
)
assert _template_visible_role({"role": "system", "content": "s"}) == "system"
def test_non_dict_is_exempt(self):
assert _template_visible_role(None) is None
assert _template_visible_role("not a message") is None
class TestSummaryRoleAlternatesAgainstVisibleNeighbours:
def test_captured_devstral_shape_emits_assistant_summary(self, compressor):
"""The byte-captured poisoning shape: protected head ends
``[user, assistant(tool_calls), tool]``, tail is all tool flow.
The literal previous role is ``tool`` (which used to pin the
summary to "user"); the template-visible previous role is
``user``, so the summary must be emitted as ``assistant``."""
c = compressor
messages = [{"role": "user", "content": "run a full systems diagnostic"}]
messages += _tool_turns(0, 30)
mocked = f"{SUMMARY_PREFIX}\nrolled-up summary of the tool work"
with patch.object(c, "_generate_summary", return_value=mocked):
out = c.compress(messages, current_tokens=90_000)
rows = _summary_rows(out)
assert len(rows) == 1
assert rows[0].get("role") == "assistant", (
"REGRESSION: compaction summary emitted as role=user directly "
"after a template-visible user turn (only exempt tool-flow "
"messages between). Mistral-strict templates reject the whole "
"request with a Jinja alternation 500 and the stored session is "
f"poisoned permanently. Got role={rows[0].get('role')!r}."
)
def test_captured_shape_passes_mistral_alternation(self, compressor):
c = compressor
messages = [{"role": "user", "content": "run a full systems diagnostic"}]
messages += _tool_turns(0, 30)
mocked = f"{SUMMARY_PREFIX}\nrolled-up summary of the tool work"
with patch.object(c, "_generate_summary", return_value=mocked):
out = c.compress(messages, current_tokens=90_000)
assert _mistral_alternation_ok(out), (
"Compressed transcript fails the Mistral template alternation "
"check. Visible-role sequence: "
f"{[_template_visible_role(m) for m in out if _template_visible_role(m)]}"
)
def test_visible_head_assistant_visible_tail_user_merges(self, compressor):
"""When the visible head ends ``assistant`` and the visible tail
opens ``user``, NO standalone role can satisfy alternation (user
collides with the tail, assistant with the head -- and the
intervening tool flow is template-exempt, so literal separation
does not help). The existing merge-into-tail fallback must fire.
The literal-role logic used to emit a standalone role="user"
summary here (literal previous role was ``tool``), which is a
second poisoning shape: visible user(summary) -> user(tail)."""
c = compressor
messages = [
{"role": "user", "content": "question " + "q" * 200},
{"role": "assistant", "content": "answer " + "a" * 200},
]
messages += _tool_turns(0, 30)
# A recent user turn preserved in the tail keeps the zero-user
# guard out of the picture.
messages += [
{"role": "user", "content": "latest question"},
{"role": "assistant", "content": "on it"},
]
mocked = f"{SUMMARY_PREFIX}\nsummary body"
with patch.object(c, "_generate_summary", return_value=mocked):
out = c.compress(messages, current_tokens=90_000)
rows = _summary_rows(out)
assert len(rows) == 1
# Merged into a template-exempt tail message: invisible to the
# alternation check, summary content still delivered.
assert _template_visible_role(rows[0]) is None
assert _mistral_alternation_ok(out)
class TestForcedUserGuardsStillWin:
def test_zero_user_guard_still_forces_user(self, compressor):
"""#58753: when no genuine user turn survives, the summary must
still be pinned to role=user (and that shape is alternation-safe
because everything after it is template-exempt)."""
c = compressor
c.compression_count = 1 # protect_first_n decays -> no head
messages = [{"role": "user", "content": "work kanban task 42"}]
messages += _tool_turns(0, 12)
mocked = f"{SUMMARY_PREFIX}\nsummary body"
with patch.object(c, "_generate_summary", return_value=mocked):
out = c.compress(messages, current_tokens=90_000)
rows = _summary_rows(out)
assert len(rows) == 1
assert rows[0].get("role") == "user"
assert _mistral_alternation_ok(out)
def test_no_literal_consecutive_user_roles(self, compressor):
"""The pre-existing literal invariant still holds alongside the
template-visible one."""
c = compressor
messages = [{"role": "user", "content": "run diagnostics"}]
messages += _tool_turns(0, 30)
mocked = f"{SUMMARY_PREFIX}\nsummary body"
with patch.object(c, "_generate_summary", return_value=mocked):
out = c.compress(messages, current_tokens=90_000)
for prev, cur in zip(out, out[1:]):
assert not (
prev.get("role") == "user" and cur.get("role") == "user"
), "compression introduced literal consecutive user-role messages"