Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -139,14 +139,15 @@ def _maybe_summarize(self, state: AgentState, runtime: Runtime) -> dict | None:
messages_to_summarize, preserved_messages = self._preserve_dynamic_context_reminders(messages_to_summarize, preserved_messages)
self._fire_hooks(messages_to_summarize, preserved_messages, runtime)
summary = self._create_summary(messages_to_summarize)
new_messages = self._build_new_messages(summary)
new_messages = self._build_hidden_summary_messages(summary)

return {
"messages": [
RemoveMessage(id=REMOVE_ALL_MESSAGES),
*new_messages,
*preserved_messages,
]
],
"display_messages": self._visible_messages(messages_to_summarize),
}

async def _amaybe_summarize(self, state: AgentState, runtime: Runtime) -> dict | None:
Expand All @@ -165,16 +166,30 @@ async def _amaybe_summarize(self, state: AgentState, runtime: Runtime) -> dict |
messages_to_summarize, preserved_messages = self._preserve_dynamic_context_reminders(messages_to_summarize, preserved_messages)
self._fire_hooks(messages_to_summarize, preserved_messages, runtime)
summary = await self._acreate_summary(messages_to_summarize)
new_messages = self._build_new_messages(summary)
new_messages = self._build_hidden_summary_messages(summary)

return {
"messages": [
RemoveMessage(id=REMOVE_ALL_MESSAGES),
*new_messages,
*preserved_messages,
]
],
"display_messages": self._visible_messages(messages_to_summarize),
}

def _build_hidden_summary_messages(self, summary: str) -> list[AnyMessage]:
new_messages = self._build_new_messages(summary)
for message in new_messages:
message.additional_kwargs = {
**getattr(message, "additional_kwargs", {}),
"hide_from_ui": True,
}
return new_messages

@staticmethod
def _visible_messages(messages: list[AnyMessage]) -> list[AnyMessage]:
return [message for message in messages if getattr(message, "additional_kwargs", {}).get("hide_from_ui") is not True]

@override
def _build_new_messages(self, summary: str) -> list[HumanMessage]:
"""Override the base implementation to let the human message with the special name 'summary'.
Expand Down
21 changes: 21 additions & 0 deletions backend/packages/harness/deerflow/agents/thread_state.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Annotated, NotRequired, TypedDict

from langchain.agents import AgentState
from langchain_core.messages import AnyMessage


class SandboxState(TypedDict):
Expand Down Expand Up @@ -45,11 +46,31 @@ def merge_viewed_images(existing: dict[str, ViewedImageData] | None, new: dict[s
return {**existing, **new}


def merge_display_messages(existing: list[AnyMessage] | None, new: list[AnyMessage] | None) -> list[AnyMessage]:
"""Reducer for UI-only messages archived before model-context summarization."""
if existing is None:
existing = []
if new is None:
return existing

merged: list[AnyMessage] = []
seen_ids: set[str] = set()
for message in [*existing, *new]:
message_id = getattr(message, "id", None)
if message_id:
if message_id in seen_ids:
continue
seen_ids.add(message_id)
merged.append(message)
return merged


class ThreadState(AgentState):
sandbox: NotRequired[SandboxState | None]
thread_data: NotRequired[ThreadDataState | None]
title: NotRequired[str | None]
artifacts: Annotated[list[str], merge_artifacts]
display_messages: Annotated[list[AnyMessage], merge_display_messages]
todos: NotRequired[list | None]
uploaded_files: NotRequired[list[dict] | None]
viewed_images: Annotated[dict[str, ViewedImageData], merge_viewed_images] # image_path -> {base64, mime_type}
15 changes: 15 additions & 0 deletions backend/tests/test_summarization_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ def test_before_summarization_hook_receives_messages_before_compression() -> Non
assert captured[0].agent_name is None
assert isinstance(result["messages"][0], RemoveMessage)
assert result["messages"][1].content.startswith("Here is a summary")
assert result["messages"][1].additional_kwargs["hide_from_ui"] is True
assert [message.content for message in result["display_messages"]] == ["user-1", "assistant-1"]


def test_dynamic_context_reminder_is_preserved_across_summarization() -> None:
Expand Down Expand Up @@ -193,6 +195,19 @@ async def test_abefore_model_calls_hooks_same_as_sync() -> None:
assert [message.content for message in captured[0].messages_to_summarize] == ["user-1", "assistant-1"]


@pytest.mark.anyio
async def test_abefore_model_returns_visible_history_after_summarization() -> None:
middleware = _middleware()

result = await middleware.abefore_model({"messages": _messages()}, _runtime())

assert isinstance(result["messages"][0], RemoveMessage)
assert result["messages"][1].content.startswith("Here is a summary")
assert result["messages"][1].additional_kwargs["hide_from_ui"] is True
assert [message.content for message in result["display_messages"]] == ["user-1", "assistant-1"]
assert [message.content for message in result["messages"][2:]] == ["user-2", "assistant-2"]


def test_memory_flush_hook_skips_when_memory_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
queue = MagicMock()
monkeypatch.setattr("deerflow.agents.memory.summarization_hook.get_memory_config", lambda: MemoryConfig(enabled=False))
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/components/workspace/messages/message-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,10 @@ export function MessageList({
const { t } = useI18n();
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const updateSubtask = useUpdateSubtask();
const messages = thread.messages;
const messages = useMemo(
() => [...(thread.values.display_messages ?? []), ...thread.messages],
[thread.messages, thread.values.display_messages],
);
const groupedMessages = getMessageGroups(messages);
const turnUsageMessagesByGroupIndex =
getAssistantTurnUsageMessages(groupedMessages);
Expand Down
1 change: 1 addition & 0 deletions frontend/src/core/threads/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Todo } from "../todos";
export interface AgentThreadState extends Record<string, unknown> {
title: string;
messages: Message[];
display_messages?: Message[];
artifacts: string[];
todos?: Todo[];
}
Expand Down
68 changes: 67 additions & 1 deletion frontend/tests/e2e/chat.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { expect, test } from "@playwright/test";

import { handleRunStream, mockLangGraphAPI } from "./utils/mock-api";
import {
handleRunStream,
MOCK_THREAD_ID_2,
mockLangGraphAPI,
} from "./utils/mock-api";

test.describe("Chat workspace", () => {
test.beforeEach(async ({ page }) => {
Expand Down Expand Up @@ -49,6 +53,68 @@ test.describe("Chat workspace", () => {
});
});

test("shows archived messages after summarization without showing the summary", async ({
page,
}) => {
await page.unrouteAll({ behavior: "ignoreErrors" });
mockLangGraphAPI(page, {
threads: [
{
thread_id: MOCK_THREAD_ID_2,
title: "LLM Wiki Report Outline",
values: {
display_messages: [
{
type: "human",
id: "archived-human-1",
content: [
{
type: "text",
text: "Generate a McKinsey-grade research report on LLM Wiki.",
},
],
},
{
type: "ai",
id: "archived-ai-1",
content: "I will gather sources and build the report outline.",
},
],
messages: [
{
type: "human",
id: "summary-hidden",
content:
"Here is a summary of the conversation to date:\n\nCore Task...",
additional_kwargs: { hide_from_ui: true },
},
{
type: "ai",
id: "current-ai-1",
content: "Continuing with the final report section.",
},
],
},
},
],
});

await page.goto(`/workspace/chats/${MOCK_THREAD_ID_2}`);

await expect(
page.getByText("Generate a McKinsey-grade research report on LLM Wiki."),
).toBeVisible({ timeout: 15_000 });
await expect(
page.getByText("I will gather sources and build the report outline."),
).toBeVisible();
await expect(
page.getByText("Continuing with the final report section."),
).toBeVisible();
await expect(
page.getByText("Here is a summary of the conversation to date"),
).toHaveCount(0);
});

test("keeps attachments visible while upload submit is pending", async ({
page,
}) => {
Expand Down
34 changes: 26 additions & 8 deletions frontend/tests/e2e/utils/mock-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* `handleRunStream` from here.
*/

import type { Message } from "@langchain/langgraph-sdk";
import type { Page, Route } from "@playwright/test";

// ---------------------------------------------------------------------------
Expand All @@ -25,6 +26,11 @@ export type MockThread = {
title?: string;
updated_at?: string;
agent_name?: string;
values?: {
title?: string;
messages?: Message[];
display_messages?: Message[];
};
};

export type MockAgent = {
Expand Down Expand Up @@ -59,7 +65,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
updated_at: t.updated_at ?? "2025-01-01T00:00:00Z",
metadata: t.agent_name ? { agent_name: t.agent_name } : {},
status: "idle",
values: { title: t.title ?? "Untitled" },
values: { title: t.title ?? t.values?.title ?? "Untitled" },
}));
return route.fulfill({
status: 200,
Expand Down Expand Up @@ -112,8 +118,12 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
body: JSON.stringify([
{
values: {
title: matchingThread.title ?? "Untitled",
messages: [
title:
matchingThread.title ??
matchingThread.values?.title ??
"Untitled",
display_messages: matchingThread.values?.display_messages,
messages: matchingThread.values?.messages ?? [
{
type: "human",
id: `msg-human-${matchingThread.thread_id}`,
Expand All @@ -122,7 +132,9 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
{
type: "ai",
id: `msg-ai-${matchingThread.thread_id}`,
content: `Response in thread ${matchingThread.title ?? matchingThread.thread_id}`,
content: `Response in thread ${
matchingThread.title ?? matchingThread.thread_id
}`,
},
],
},
Expand Down Expand Up @@ -153,9 +165,13 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
contentType: "application/json",
body: JSON.stringify({
values: {
title: matchingThread?.title ?? "Untitled",
title:
matchingThread?.title ??
matchingThread?.values?.title ??
"Untitled",
display_messages: matchingThread?.values?.display_messages,
messages: matchingThread
? [
? (matchingThread.values?.messages ?? [
{
type: "human",
id: `msg-human-${matchingThread.thread_id}`,
Expand All @@ -164,9 +180,11 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
{
type: "ai",
id: `msg-ai-${matchingThread.thread_id}`,
content: `Response in thread ${matchingThread.title ?? matchingThread.thread_id}`,
content: `Response in thread ${
matchingThread.title ?? matchingThread.thread_id
}`,
},
]
])
: [],
},
next: [],
Expand Down
Loading