diff --git a/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py b/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py index af0881e88..d6a149d32 100644 --- a/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py +++ b/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py @@ -10,6 +10,7 @@ from typing import Any, Protocol, override, runtime_checkable from langchain.agents import AgentState from langchain.agents.middleware import SummarizationMiddleware from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, RemoveMessage, ToolMessage +from langchain_core.messages.utils import get_buffer_string from langgraph.config import get_config from langgraph.graph.message import REMOVE_ALL_MESSAGES from langgraph.runtime import Runtime @@ -175,12 +176,76 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware): ] } + @override + def _create_summary(self, messages_to_summarize: list[AnyMessage]) -> str: + """Generate summary without emitting streaming events to the client. + + Suppresses callbacks to prevent the internal summarization LLM call from + producing visible AI message chunks in the frontend's ``messages-tuple`` + stream (issue #2804). + """ + if not messages_to_summarize: + return "No previous conversation history." + + trimmed = self._trim_messages_for_summary(messages_to_summarize) + if not trimmed: + return "Previous conversation was too long to summarize." + + formatted = get_buffer_string(trimmed) + + try: + response = self.model.invoke( + self.summary_prompt.format(messages=formatted).rstrip(), + config={ + "metadata": {"lc_source": "summarization"}, + "callbacks": [], + }, + ) + return response.text.strip() + except Exception as e: + return f"Error generating summary: {e!s}" + + @override + async def _acreate_summary(self, messages_to_summarize: list[AnyMessage]) -> str: + """Generate summary without emitting streaming events to the client. + + Suppresses callbacks to prevent the internal summarization LLM call from + producing visible AI message chunks in the frontend's ``messages-tuple`` + stream (issue #2804). + """ + if not messages_to_summarize: + return "No previous conversation history." + + trimmed = self._trim_messages_for_summary(messages_to_summarize) + if not trimmed: + return "Previous conversation was too long to summarize." + + formatted = get_buffer_string(trimmed) + + try: + response = await self.model.ainvoke( + self.summary_prompt.format(messages=formatted).rstrip(), + config={ + "metadata": {"lc_source": "summarization"}, + "callbacks": [], + }, + ) + return response.text.strip() + except Exception as e: + return f"Error generating summary: {e!s}" + @override def _build_new_messages(self, summary: str) -> list[HumanMessage]: """Override the base implementation to let the human message with the special name 'summary'. And this message will be ignored to display in the frontend, but still can be used as context for the model. """ - return [HumanMessage(content=f"Here is a summary of the conversation to date:\n\n{summary}", name="summary")] + return [ + HumanMessage( + content=f"Here is a summary of the conversation to date:\n\n{summary}", + name="summary", + additional_kwargs={"hide_from_ui": True}, + ) + ] def _preserve_dynamic_context_reminders( self, diff --git a/backend/tests/test_summarization_middleware.py b/backend/tests/test_summarization_middleware.py index cbd94e434..8729374e7 100644 --- a/backend/tests/test_summarization_middleware.py +++ b/backend/tests/test_summarization_middleware.py @@ -634,3 +634,62 @@ def test_memory_flush_hook_preserves_agent_scoped_memory(monkeypatch: pytest.Mon queue.add_nowait.assert_called_once() assert queue.add_nowait.call_args.kwargs["agent_name"] == "research-agent" + + +# --------------------------------------------------------------------------- +# Issue #2804: summary text must not leak to the frontend via streaming +# --------------------------------------------------------------------------- + + +def test_build_new_messages_sets_hide_from_ui() -> None: + """The summary HumanMessage must carry hide_from_ui so the frontend filters it.""" + middleware = _middleware() + messages = middleware._build_new_messages("test summary") + + assert len(messages) == 1 + msg = messages[0] + assert msg.name == "summary" + assert msg.additional_kwargs.get("hide_from_ui") is True + assert "test summary" in msg.content + + +def test_create_summary_suppresses_callbacks() -> None: + """_create_summary must pass callbacks=[] to prevent the internal LLM call + from producing visible streaming events in the frontend (issue #2804).""" + middleware = _middleware() + + middleware._create_summary(_messages()) + + middleware.model.invoke.assert_called_once() + call_config = middleware.model.invoke.call_args.kwargs.get("config") or middleware.model.invoke.call_args[1].get("config") + assert call_config is not None + assert call_config.get("callbacks") == [] + assert call_config.get("metadata", {}).get("lc_source") == "summarization" + + +@pytest.mark.anyio +async def test_acreate_summary_suppresses_callbacks() -> None: + """_acreate_summary must pass callbacks=[] to prevent the internal LLM call + from producing visible streaming events in the frontend (issue #2804).""" + middleware = _middleware() + middleware.model.ainvoke = mock.AsyncMock(return_value=SimpleNamespace(text="async summary")) + + await middleware._acreate_summary(_messages()) + + middleware.model.ainvoke.assert_called_once() + call_config = middleware.model.ainvoke.call_args.kwargs.get("config") or middleware.model.ainvoke.call_args[1].get("config") + assert call_config is not None + assert call_config.get("callbacks") == [] + assert call_config.get("metadata", {}).get("lc_source") == "summarization" + + +def test_before_model_summary_message_has_hide_from_ui() -> None: + """End-to-end: the emitted state update contains a summary message with hide_from_ui.""" + middleware = _middleware() + + result = middleware.before_model({"messages": _messages()}, _runtime()) + + emitted = result["messages"] + summary_msg = emitted[1] + assert summary_msg.name == "summary" + assert summary_msg.additional_kwargs.get("hide_from_ui") is True