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:
liuhao1024 2026-06-10 08:27:34 +08:00 committed by Teknium
parent 75099ca0ef
commit 9bb253d4fa
2 changed files with 188 additions and 6 deletions

View file

@ -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 "")

View file

@ -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),
}