mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
fix(tools): filter compaction summaries from session_search bookends and cap content length
Context-compaction handoff summaries (prefixed with [CONTEXT COMPACTION]) were being returned as normal bookend_start/bookend_end messages in session_search discovery mode. A single compaction handoff could be 57K+ chars, immediately bloating a fresh session prompt to 73K+ chars from one search hit. Changes: - Add _COMPACTION_PREFIXES and _is_compaction_summary() helper - Filter compaction summaries from bookend_start and bookend_end in _discover() - Cap bookend content to 1200 chars and window content to 4000 chars - Add content_truncated/original_content_chars metadata when truncation occurs - Add 6 regression tests covering prefix detection, bookend filtering, content capping, and legacy [CONTEXT SUMMARY] prefix Fixes #43175
This commit is contained in:
parent
75099ca0ef
commit
9bb253d4fa
2 changed files with 188 additions and 6 deletions
|
|
@ -638,3 +638,137 @@ class TestCronDemotion:
|
|||
# Interactive rows first, in original relative order; cron last, in
|
||||
# original relative order.
|
||||
assert [r["id"] for r in ordered] == [2, 4, 5, 1, 3]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Compaction summary filtering (#43175)
|
||||
# =========================================================================
|
||||
|
||||
class TestCompactionSummaryFiltering:
|
||||
"""session_search discovery must exclude compaction handoffs from bookends."""
|
||||
|
||||
def test_is_compaction_summary_detects_prefix(self):
|
||||
from tools.session_search_tool import _is_compaction_summary
|
||||
assert _is_compaction_summary("[CONTEXT COMPACTION — REFERENCE ONLY] foo")
|
||||
assert _is_compaction_summary("[CONTEXT SUMMARY]: old summary")
|
||||
assert not _is_compaction_summary("Hello, how can I help?")
|
||||
assert not _is_compaction_summary("")
|
||||
assert not _is_compaction_summary(None)
|
||||
|
||||
def test_compaction_summary_excluded_from_bookend_start(self, db):
|
||||
"""Compaction handoff in bookend_start position must be filtered out."""
|
||||
db.create_session("s_compact", source="cli")
|
||||
# First message: a compaction handoff (should be filtered)
|
||||
db.append_message("s_compact", role="user",
|
||||
content="[CONTEXT COMPACTION — REFERENCE ONLY] "
|
||||
"Earlier turns were compacted into the summary below. " + "x" * 50000)
|
||||
# Second message: normal user message
|
||||
db.append_message("s_compact", role="user", content="Fix the zorgblat rendering bug")
|
||||
# Padding messages to push window away from session start (so bookend has room)
|
||||
for i in range(10):
|
||||
db.append_message("s_compact", role="user", content=f"setup step {i}")
|
||||
db.append_message("s_compact", role="assistant", content=f"setup done {i}")
|
||||
# Match target: uses a unique term so FTS5 anchors here, not at the start
|
||||
db.append_message("s_compact", role="user", content="investigate the frobnitz mob spawning in KubeJS")
|
||||
db.append_message("s_compact", role="assistant", content="I'll look into the frobnitz mob spawning issue.")
|
||||
# Tail messages
|
||||
for i in range(5):
|
||||
db.append_message("s_compact", role="user", content=f"tail {i}")
|
||||
db.append_message("s_compact", role="assistant", content=f"done tail {i}")
|
||||
db._conn.commit()
|
||||
|
||||
result = json.loads(session_search(query="frobnitz mob spawning", db=db, limit=1))
|
||||
assert result["success"] is True
|
||||
assert len(result["results"]) >= 1
|
||||
entry = result["results"][0]
|
||||
# bookend_start must NOT contain the compaction handoff
|
||||
for msg in entry.get("bookend_start", []):
|
||||
assert "[CONTEXT COMPACTION" not in (msg.get("content") or "")
|
||||
# The normal message should still be present in bookend_start
|
||||
bookend_contents = [m.get("content", "") for m in entry.get("bookend_start", [])]
|
||||
assert any("zorgblat" in c for c in bookend_contents)
|
||||
|
||||
def test_compaction_summary_excluded_from_bookend_end(self, db):
|
||||
"""Compaction handoff in bookend_end position must be filtered out."""
|
||||
db.create_session("s_compact_end", source="cli")
|
||||
# Normal opening
|
||||
db.append_message("s_compact_end", role="user", content="Build a website")
|
||||
db.append_message("s_compact_end", role="assistant", content="Sure, let me scaffold it.")
|
||||
# Match target (early in session so bookend_end has room)
|
||||
db.append_message("s_compact_end", role="user", content="fix the zorgblat rendering bug")
|
||||
db.append_message("s_compact_end", role="assistant", content="Investigating the zorgblat rendering issue.")
|
||||
# Many messages to create distance from the end
|
||||
for i in range(10):
|
||||
db.append_message("s_compact_end", role="user", content=f"feature {i}")
|
||||
db.append_message("s_compact_end", role="assistant", content=f"implemented {i}")
|
||||
# Last message: compaction handoff (should be filtered from bookend_end)
|
||||
db.append_message("s_compact_end", role="assistant",
|
||||
content="[CONTEXT COMPACTION — REFERENCE ONLY] "
|
||||
"Summary of all work done. " + "y" * 50000)
|
||||
db._conn.commit()
|
||||
|
||||
result = json.loads(session_search(query="zorgblat rendering", db=db, limit=1))
|
||||
assert result["success"] is True
|
||||
assert len(result["results"]) >= 1
|
||||
entry = result["results"][0]
|
||||
# bookend_end must NOT contain the compaction handoff
|
||||
for msg in entry.get("bookend_end", []):
|
||||
assert "[CONTEXT COMPACTION" not in (msg.get("content") or "")
|
||||
|
||||
def test_bookend_content_is_capped(self, db):
|
||||
"""Bookend messages must have content capped at 1200 chars."""
|
||||
db.create_session("s_long_bookend", source="cli")
|
||||
# First message: very long normal content
|
||||
db.append_message("s_long_bookend", role="user",
|
||||
content="Start the project. " + "z" * 5000)
|
||||
# Match target
|
||||
db.append_message("s_long_bookend", role="user", content="deploy to production")
|
||||
db.append_message("s_long_bookend", role="assistant", content="Deploying now.")
|
||||
for i in range(10):
|
||||
db.append_message("s_long_bookend", role="user", content=f"step {i}")
|
||||
db.append_message("s_long_bookend", role="assistant", content=f"done {i}")
|
||||
db._conn.commit()
|
||||
|
||||
result = json.loads(session_search(query="deploy production", db=db, limit=1))
|
||||
assert result["success"] is True
|
||||
entry = result["results"][0]
|
||||
for msg in entry.get("bookend_start", []):
|
||||
content = msg.get("content", "")
|
||||
# Content should be capped (1200 chars + "…" ellipsis)
|
||||
assert len(content) <= 1210 # 1200 + ellipsis + margin
|
||||
if msg.get("content_truncated"):
|
||||
assert msg["original_content_chars"] > 1200
|
||||
|
||||
def test_window_content_is_capped(self, db):
|
||||
"""Window messages must have content capped at 4000 chars."""
|
||||
db.create_session("s_long_window", source="cli")
|
||||
db.append_message("s_long_window", role="user", content="search keyword here")
|
||||
# Very long assistant reply containing the keyword
|
||||
db.append_message("s_long_window", role="assistant",
|
||||
content="Found it! keyword " + "a" * 10000)
|
||||
db._conn.commit()
|
||||
|
||||
result = json.loads(session_search(query="keyword", db=db, limit=1))
|
||||
assert result["success"] is True
|
||||
entry = result["results"][0]
|
||||
for msg in entry.get("messages", []):
|
||||
content = msg.get("content", "")
|
||||
assert len(content) <= 4010 # 4000 + ellipsis + margin
|
||||
|
||||
def test_legacy_context_summary_filtered(self, db):
|
||||
"""Legacy [CONTEXT SUMMARY]: prefix must also be filtered."""
|
||||
db.create_session("s_legacy", source="cli")
|
||||
db.append_message("s_legacy", role="user",
|
||||
content="[CONTEXT SUMMARY]: old compacted summary here")
|
||||
db.append_message("s_legacy", role="user", content="new task: build API")
|
||||
db.append_message("s_legacy", role="assistant", content="Building REST API now.")
|
||||
for i in range(10):
|
||||
db.append_message("s_legacy", role="user", content=f"step {i}")
|
||||
db.append_message("s_legacy", role="assistant", content=f"done {i}")
|
||||
db._conn.commit()
|
||||
|
||||
result = json.loads(session_search(query="build API", db=db, limit=1))
|
||||
assert result["success"] is True
|
||||
entry = result["results"][0]
|
||||
for msg in entry.get("bookend_start", []):
|
||||
assert "[CONTEXT SUMMARY]" not in (msg.get("content") or "")
|
||||
|
|
|
|||
|
|
@ -55,6 +55,16 @@ _DEMOTED_SESSION_SOURCES = ("cron",)
|
|||
# the handful of distinct sessions a typical query returns.
|
||||
_DISCOVER_SCAN_LIMIT = 300
|
||||
|
||||
# Prefixes that identify generated context-compaction handoff summaries.
|
||||
# These are inserted by agent/context_compressor.py as normal user/assistant
|
||||
# messages but contain machine-generated summary metadata — not user content.
|
||||
# They must be excluded from discovery bookends to avoid re-introducing huge
|
||||
# compaction payloads into fresh sessions via session_search. (#43175)
|
||||
_COMPACTION_PREFIXES = (
|
||||
"[CONTEXT COMPACTION",
|
||||
"[CONTEXT SUMMARY]:",
|
||||
)
|
||||
|
||||
|
||||
def _format_timestamp(ts: Union[int, float, str, None]) -> str:
|
||||
"""Convert a Unix timestamp (float/int) or ISO string to a human-readable date.
|
||||
|
|
@ -81,6 +91,15 @@ def _format_timestamp(ts: Union[int, float, str, None]) -> str:
|
|||
return str(ts)
|
||||
|
||||
|
||||
def _is_compaction_summary(content: str) -> bool:
|
||||
"""Return True if *content* looks like a generated compaction handoff."""
|
||||
if not content:
|
||||
return False
|
||||
stripped = content.lstrip()
|
||||
return any(stripped.startswith(p) for p in _COMPACTION_PREFIXES)
|
||||
|
||||
|
||||
|
||||
def _resolve_to_parent(db, session_id: str) -> str:
|
||||
"""Walk parent_session_id chain to the lineage root. Falls back to input on errors."""
|
||||
if not session_id:
|
||||
|
|
@ -142,12 +161,30 @@ def _order_for_recall(raw_results: List[Dict[str, Any]]) -> List[Dict[str, Any]]
|
|||
)
|
||||
|
||||
|
||||
def _shape_message(m: Dict[str, Any], anchor_id: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""Slim a message row for the tool response. Keeps content even if empty."""
|
||||
def _shape_message(
|
||||
m: Dict[str, Any],
|
||||
anchor_id: Optional[int] = None,
|
||||
max_content_len: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Slim a message row for the tool response. Keeps content even if empty.
|
||||
|
||||
When *max_content_len* is set, ``content`` is truncated to that many
|
||||
characters and ``content_truncated`` / ``original_content_chars`` metadata
|
||||
is added so callers know the payload was bounded.
|
||||
"""
|
||||
raw_content = m.get("content")
|
||||
if max_content_len and raw_content and len(raw_content) > max_content_len:
|
||||
content = raw_content[:max_content_len] + "…"
|
||||
truncated = True
|
||||
original_chars = len(raw_content)
|
||||
else:
|
||||
content = raw_content
|
||||
truncated = False
|
||||
original_chars = None
|
||||
entry = {
|
||||
"id": m.get("id"),
|
||||
"role": m.get("role"),
|
||||
"content": m.get("content"),
|
||||
"content": content,
|
||||
"timestamp": m.get("timestamp"),
|
||||
}
|
||||
if m.get("tool_name"):
|
||||
|
|
@ -158,6 +195,9 @@ def _shape_message(m: Dict[str, Any], anchor_id: Optional[int] = None) -> Dict[s
|
|||
entry["tool_call_id"] = m.get("tool_call_id")
|
||||
if anchor_id is not None and m.get("id") == anchor_id:
|
||||
entry["anchor"] = True
|
||||
if truncated:
|
||||
entry["content_truncated"] = True
|
||||
entry["original_content_chars"] = original_chars
|
||||
# Strip None values to keep payload tight, but always keep content
|
||||
# (absent content is meaningful — tool-call-only assistant turns).
|
||||
return {k: v for k, v in entry.items() if v is not None or k in ("content",)}
|
||||
|
|
@ -620,9 +660,17 @@ def _discover(
|
|||
"matched_role": match_info.get("role"),
|
||||
"match_message_id": msg_id,
|
||||
"snippet": match_info.get("snippet") or "",
|
||||
"bookend_start": [_shape_message(m) for m in (view.get("bookend_start") or [])],
|
||||
"messages": [_shape_message(m, anchor_id=msg_id) for m in (view.get("window") or [])],
|
||||
"bookend_end": [_shape_message(m) for m in (view.get("bookend_end") or [])],
|
||||
"bookend_start": [
|
||||
_shape_message(m, max_content_len=1200)
|
||||
for m in (view.get("bookend_start") or [])
|
||||
if not _is_compaction_summary(m.get("content", ""))
|
||||
],
|
||||
"messages": [_shape_message(m, anchor_id=msg_id, max_content_len=4000) for m in (view.get("window") or [])],
|
||||
"bookend_end": [
|
||||
_shape_message(m, max_content_len=1200)
|
||||
for m in (view.get("bookend_end") or [])
|
||||
if not _is_compaction_summary(m.get("content", ""))
|
||||
],
|
||||
"messages_before": view.get("messages_before", 0),
|
||||
"messages_after": view.get("messages_after", 0),
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue