mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-11 03:31:55 +00:00
fix(compression): preserve iterative summary continuity
This commit is contained in:
parent
f8a6db68ca
commit
4a3e3e20e5
2 changed files with 105 additions and 5 deletions
|
|
@ -993,15 +993,39 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _with_summary_prefix(summary: str) -> str:
|
def _strip_summary_prefix(summary: str) -> str:
|
||||||
"""Normalize summary text to the current compaction handoff format."""
|
"""Return summary body without the current or legacy handoff prefix."""
|
||||||
text = (summary or "").strip()
|
text = (summary or "").strip()
|
||||||
for prefix in (LEGACY_SUMMARY_PREFIX, SUMMARY_PREFIX):
|
for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX):
|
||||||
if text.startswith(prefix):
|
if text.startswith(prefix):
|
||||||
text = text[len(prefix):].lstrip()
|
return text[len(prefix):].lstrip()
|
||||||
break
|
return text
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _with_summary_prefix(cls, summary: str) -> str:
|
||||||
|
"""Normalize summary text to the current compaction handoff format."""
|
||||||
|
text = cls._strip_summary_prefix(summary)
|
||||||
return f"{SUMMARY_PREFIX}\n{text}" if text else SUMMARY_PREFIX
|
return f"{SUMMARY_PREFIX}\n{text}" if text else SUMMARY_PREFIX
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_context_summary_content(content: Any) -> bool:
|
||||||
|
text = _content_text_for_contains(content).lstrip()
|
||||||
|
return text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _find_latest_context_summary(
|
||||||
|
cls,
|
||||||
|
messages: List[Dict[str, Any]],
|
||||||
|
start: int,
|
||||||
|
end: int,
|
||||||
|
) -> tuple[Optional[int], str]:
|
||||||
|
"""Find the newest handoff summary inside a compression window."""
|
||||||
|
for idx in range(end - 1, start - 1, -1):
|
||||||
|
content = messages[idx].get("content")
|
||||||
|
if cls._is_context_summary_content(content):
|
||||||
|
return idx, cls._strip_summary_prefix(_content_text_for_contains(content))
|
||||||
|
return None, ""
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Tool-call / tool-result pair integrity helpers
|
# Tool-call / tool-result pair integrity helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -1308,6 +1332,15 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
turns_to_summarize = messages[compress_start:compress_end]
|
turns_to_summarize = messages[compress_start:compress_end]
|
||||||
|
summary_idx, summary_body = self._find_latest_context_summary(
|
||||||
|
messages,
|
||||||
|
compress_start,
|
||||||
|
compress_end,
|
||||||
|
)
|
||||||
|
if summary_idx is not None:
|
||||||
|
if summary_body and not self._previous_summary:
|
||||||
|
self._previous_summary = summary_body
|
||||||
|
turns_to_summarize = messages[summary_idx + 1:compress_end]
|
||||||
|
|
||||||
if not self.quiet_mode:
|
if not self.quiet_mode:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
67
tests/agent/test_context_compressor_summary_continuity.py
Normal file
67
tests/agent/test_context_compressor_summary_continuity.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""Regression tests for iterative context-summary continuity."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from agent.context_compressor import ContextCompressor, SUMMARY_PREFIX
|
||||||
|
|
||||||
|
|
||||||
|
def _compressor() -> ContextCompressor:
|
||||||
|
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
|
||||||
|
return ContextCompressor(
|
||||||
|
model="test/model",
|
||||||
|
threshold_percent=0.85,
|
||||||
|
protect_first_n=1,
|
||||||
|
protect_last_n=1,
|
||||||
|
quiet_mode=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _response(content: str):
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.choices = [MagicMock()]
|
||||||
|
mock_response.choices[0].message.content = content
|
||||||
|
return mock_response
|
||||||
|
|
||||||
|
|
||||||
|
def _messages_with_handoff(summary_body: str):
|
||||||
|
return [
|
||||||
|
{"role": "system", "content": "system prompt"},
|
||||||
|
{"role": "user", "content": f"{SUMMARY_PREFIX}\n{summary_body}"},
|
||||||
|
{"role": "user", "content": "new user turn after resume"},
|
||||||
|
{"role": "assistant", "content": "new assistant work after resume"},
|
||||||
|
{"role": "user", "content": "more new work after resume"},
|
||||||
|
{"role": "assistant", "content": "latest tail response"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_previous_summary_is_not_serialized_again_as_new_turn():
|
||||||
|
"""Same-process iterative compression should not feed the old handoff twice."""
|
||||||
|
compressor = _compressor()
|
||||||
|
old_summary = "OLD-SUMMARY-BODY unique continuity facts"
|
||||||
|
compressor._previous_summary = old_summary
|
||||||
|
|
||||||
|
with patch("agent.context_compressor.call_llm", return_value=_response("updated summary")) as mock_call:
|
||||||
|
compressor.compress(_messages_with_handoff(old_summary))
|
||||||
|
|
||||||
|
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
|
||||||
|
assert "PREVIOUS SUMMARY:" in prompt
|
||||||
|
assert "NEW TURNS TO INCORPORATE:" in prompt
|
||||||
|
assert prompt.count(old_summary) == 1
|
||||||
|
assert f"[USER]: {SUMMARY_PREFIX}" not in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_rehydrates_previous_summary_from_handoff_message():
|
||||||
|
"""After restart/resume, the persisted handoff should regain summary identity."""
|
||||||
|
compressor = _compressor()
|
||||||
|
old_summary = "RESUMED-SUMMARY-BODY durable continuity facts"
|
||||||
|
assert compressor._previous_summary is None
|
||||||
|
|
||||||
|
with patch("agent.context_compressor.call_llm", return_value=_response("updated summary")) as mock_call:
|
||||||
|
compressor.compress(_messages_with_handoff(old_summary))
|
||||||
|
|
||||||
|
prompt = mock_call.call_args.kwargs["messages"][0]["content"]
|
||||||
|
assert "PREVIOUS SUMMARY:" in prompt
|
||||||
|
assert "NEW TURNS TO INCORPORATE:" in prompt
|
||||||
|
assert "TURNS TO SUMMARIZE:" not in prompt
|
||||||
|
assert prompt.count(old_summary) == 1
|
||||||
|
assert f"[USER]: {SUMMARY_PREFIX}" not in prompt
|
||||||
Loading…
Add table
Add a link
Reference in a new issue