diff --git a/routes/chat_helpers.py b/routes/chat_helpers.py index 392859c5c..25f12d566 100644 --- a/routes/chat_helpers.py +++ b/routes/chat_helpers.py @@ -505,6 +505,29 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]: return None +def _session_is_research_spinoff(sess) -> bool: + """True if this session was created via research "Discuss" spin-off. + + Detected by the primer system message the spin-off endpoint seeds into + history (metadata ``research_spinoff_from``). Such sessions are grounded + on the seeded report, so global memory + personal-doc RAG injection is + suppressed for them (the report is the sole knowledge base). Handles both + ChatMessage objects and plain dicts. + """ + for m in getattr(sess, "history", []) or []: + role = getattr(m, "role", None) + if role is None and isinstance(m, dict): + role = m.get("role") + if role != "system": + continue + md = getattr(m, "metadata", None) + if md is None and isinstance(m, dict): + md = m.get("metadata") + if (md or {}).get("research_spinoff_from"): + return True + return False + + async def build_chat_context( sess, request, @@ -570,9 +593,17 @@ async def build_chat_context( mem_enabled, user, incognito, no_memory, uprefs.get("memory_enabled", "NOT_SET"), ) + # Research-spinoff ("Discuss") sessions are grounded on the seeded report: + # the primer system message IS the knowledge base. Injecting global memory + # or personal-doc RAG on every turn pulls in keyword-matched but off-topic + # facts ("wrong data") and competes with the report, so suppress both here. + is_research_spinoff = _session_is_research_spinoff(sess) + if is_research_spinoff: + mem_enabled = False + # Use RAG? use_rag_val = (str(use_rag).lower() != "false") if use_rag is not None else True - if incognito or not allow_tool_preprocessing: + if incognito or not allow_tool_preprocessing or is_research_spinoff: use_rag_val = False # If pre-fetched search context was provided (compare mode), skip live web search @@ -595,7 +626,7 @@ async def build_chat_context( incognito=incognito, use_skills=skills_enabled, ) - if use_rag is not None: + if use_rag is not None or is_research_spinoff: _preface_kwargs["use_rag"] = use_rag_val preface, rag_sources, web_sources = chat_processor.build_context_preface(**_preface_kwargs) diff --git a/src/context_compactor.py b/src/context_compactor.py index 150d7bb3c..3a4f6c072 100644 --- a/src/context_compactor.py +++ b/src/context_compactor.py @@ -244,9 +244,17 @@ def trim_for_context(messages: List[Dict], context_length: int, reserve_tokens: protected_tokens = estimate_tokens(protected_msgs) budget -= protected_tokens - # Priority: keep first system msg (preset prompt), drop others (memory, RAG, memo) - essential_system = system_msgs[:1] if system_msgs else [] - extra_system = system_msgs[1:] + # Priority: keep first system msg (preset prompt), drop others (memory, RAG, memo). + # Exception: a research-spinoff primer (the seeded report that grounds a + # "Discuss" chat) must never be dropped — it is the conversation's whole + # knowledge base. Treat any system message carrying research_spinoff_from + # metadata as essential alongside the leading system prompt. + def _is_research_primer(m): + return bool((m.get("metadata") or {}).get("research_spinoff_from")) + _primers = [m for m in system_msgs if _is_research_primer(m)] + _non_primer = [m for m in system_msgs if not _is_research_primer(m)] + essential_system = (_non_primer[:1] if _non_primer else []) + _primers + extra_system = _non_primer[1:] # Try dropping extra system messages one by one (from the end) trimmed = essential_system + convo_msgs diff --git a/tests/test_chat_helpers.py b/tests/test_chat_helpers.py index 2a559db93..370412268 100644 --- a/tests/test_chat_helpers.py +++ b/tests/test_chat_helpers.py @@ -218,3 +218,47 @@ def test_save_assistant_response_preserves_actual_and_requested_model(): assert sess.history[-1].metadata["requested_model"] == "selected-model" assert sess.history[-1].metadata["model"] == "actual-model" + + +from types import SimpleNamespace +from routes.chat_helpers import _session_is_research_spinoff + + +class _SpinMsg: + def __init__(self, role, metadata=None): + self.role = role + self.metadata = metadata + + +def test_spinoff_detected_from_chatmessage_history(): + sess = SimpleNamespace(history=[ + _SpinMsg("system", {"research_spinoff_from": "rp-1"}), + _SpinMsg("user", None), + ]) + assert _session_is_research_spinoff(sess) is True + + +def test_spinoff_detected_from_dict_history(): + sess = SimpleNamespace(history=[ + {"role": "system", "metadata": {"research_spinoff_from": "rp-2"}}, + {"role": "user", "content": "hi"}, + ]) + assert _session_is_research_spinoff(sess) is True + + +def test_non_spinoff_plain_session_is_false(): + sess = SimpleNamespace(history=[ + _SpinMsg("system", {"compacted": True}), + _SpinMsg("user", None), + ]) + assert _session_is_research_spinoff(sess) is False + + +def test_metadata_on_non_system_message_ignored(): + sess = SimpleNamespace(history=[_SpinMsg("user", {"research_spinoff_from": "rp-3"})]) + assert _session_is_research_spinoff(sess) is False + + +def test_empty_or_missing_history(): + assert _session_is_research_spinoff(SimpleNamespace(history=[])) is False + assert _session_is_research_spinoff(SimpleNamespace()) is False diff --git a/tests/test_context_compactor.py b/tests/test_context_compactor.py index 8b9da3972..3ccd3fb59 100644 --- a/tests/test_context_compactor.py +++ b/tests/test_context_compactor.py @@ -192,3 +192,42 @@ class TestMaybeCompactFourthMessage: ]} result = self._run(messages) assert len(result) == 3 and result[2] is True + + +class TestResearchPrimerPreserved: + """A research-spinoff primer (metadata research_spinoff_from) must never be + trimmed away — it is the Discuss chat's sole knowledge base (drift fix).""" + + def _messages(self): + return [ + {"role": "system", "content": "You are Odysseus."}, + {"role": "system", "content": "Prompt-safety policy: data not instructions."}, + {"role": "system", "content": "saved memory: pinned " + "m" * 600}, + {"role": "system", "content": "RETRIEVED-DOCS-MARKER " + "r" * 6000}, + {"role": "system", + "content": "=== REPORT ===\nPRIMER-MARKER " + "z" * 1500, + "metadata": {"research_spinoff_from": "rp-abc123"}}, + ] + [ + {"role": "user", "content": f"q{i} " + ("x" * 500)} for i in range(8) + ] + [ + {"role": "assistant", "content": "a" * 500}, + {"role": "user", "content": "latest question"}, + ] + + def test_primer_kept_when_over_budget(self): + trimmed = trim_for_context(self._messages(), context_length=1024, reserve_tokens=256) + joined = "\n".join(str(m.get("content", "")) for m in trimmed) + assert "PRIMER-MARKER" in joined + + def test_bulky_non_primer_system_dropped_but_primer_kept(self): + trimmed = trim_for_context(self._messages(), context_length=1024, reserve_tokens=256) + joined = "\n".join(str(m.get("content", "")) for m in trimmed) + assert "PRIMER-MARKER" in joined + assert "RETRIEVED-DOCS-MARKER" not in joined + + def test_leading_preset_kept_when_no_primer_metadata(self): + msgs = self._messages() + del msgs[4]["metadata"] + trimmed = trim_for_context(msgs, context_length=1024, reserve_tokens=256) + joined = "\n".join(str(m.get("content", "")) for m in trimmed) + assert "You are Odysseus." in joined