From 9bb253d4fa1dac4c8b299a803fe90df7891050a7 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Wed, 10 Jun 2026 08:27:34 +0800 Subject: [PATCH] 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 --- tests/tools/test_session_search.py | 134 +++++++++++++++++++++++++++++ tools/session_search_tool.py | 60 +++++++++++-- 2 files changed, 188 insertions(+), 6 deletions(-) diff --git a/tests/tools/test_session_search.py b/tests/tools/test_session_search.py index ec879f18f096..69e3adb1d762 100644 --- a/tests/tools/test_session_search.py +++ b/tests/tools/test_session_search.py @@ -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 "") diff --git a/tools/session_search_tool.py b/tools/session_search_tool.py index aeb34d46f3b9..f2cf85a3a9d1 100644 --- a/tools/session_search_tool.py +++ b/tools/session_search_tool.py @@ -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), }