From 4e7901018f412e0e79aa1e45edb67dffe366ba8e Mon Sep 17 00:00:00 2001 From: Xi Zhang Date: Wed, 10 Jun 2026 12:23:50 +0100 Subject: [PATCH] feat: update version to 0.0.8 and implement conversation compaction summary feature --- package-lock.json | 4 +- package.json | 2 +- src/app/components/ChatInterface.tsx | 86 +++++++++++---- src/app/components/CompactionSummary.tsx | 68 ++++++++++++ src/app/hooks/useChat.ts | 8 ++ src/lib/summarization.ts | 135 +++++++++++++++++++++++ 6 files changed, 279 insertions(+), 24 deletions(-) create mode 100644 src/app/components/CompactionSummary.tsx create mode 100644 src/lib/summarization.ts diff --git a/package-lock.json b/package-lock.json index f0d500d..e213b2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@evoscientist/webui", - "version": "0.0.7", + "version": "0.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@evoscientist/webui", - "version": "0.0.7", + "version": "0.0.8", "license": "Apache-2.0", "dependencies": { "@langchain/core": "1.1.19", diff --git a/package.json b/package.json index 0bf28c5..5504219 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@evoscientist/webui", - "version": "0.0.7", + "version": "0.0.8", "description": "Web UI for EvoScientist — a self-evolving AI scientist built on DeepAgents/LangGraph.", "author": "Xi Zhang ", "type": "module", diff --git a/src/app/components/ChatInterface.tsx b/src/app/components/ChatInterface.tsx index 1b433e8..09849c9 100644 --- a/src/app/components/ChatInterface.tsx +++ b/src/app/components/ChatInterface.tsx @@ -25,6 +25,8 @@ import { X, } from "lucide-react"; import { ChatMessage } from "@/app/components/ChatMessage"; +import { CompactionSummary } from "@/app/components/CompactionSummary"; +import { isSummarizationMessage } from "@/lib/summarization"; import { AskUserInterrupt, type AskUserQuestion, @@ -222,6 +224,7 @@ export const ChatInterface = React.memo( resumeInterrupt, subAgentActivity, asyncTasks, + summarizationEvent, } = useChatContext(); // Count of background async sub-agents (writing / data-analysis) still @@ -660,6 +663,13 @@ export const ChatInterface = React.memo( const meta = stream.getMessagesMetadata(message)?.streamMetadata; const ns = meta?.["langgraph_checkpoint_ns"]; if (typeof ns === "string" && ns.includes("|")) return false; + // The conversation-compaction summary is generated by a SEPARATE LLM + // call (its own "Context Extraction Assistant" system prompt, like the + // tool-selector). Its output transiently leaks into the raw stream as an + // AI message (## SESSION INTENT / ## SUMMARY / …) then vanishes — it is + // never persisted in `messages`. Drop it here; the stable summary is + // surfaced from `_summarization_event` as a collapsible block instead. + if (isSummarizationMessage(message)) return false; if (message.type !== "human") return true; const key = asyncUpdateMessageKey( extractStringFromMessageContent(message) @@ -738,6 +748,25 @@ export const ChatInterface = React.memo( }); }, [messages, interrupt, isLoading, stream]); + // Where to anchor the "Conversation compacted" block. The event's + // cutoffIndex points into the raw `messages` array (messages[0:cutoff] were + // summarized); we render the block right before the first message AFTER the + // cutoff so it reads as "everything above was folded into this summary". If + // that boundary message isn't in the rendered list (e.g. cutoff past the + // end), fall back to appending the block after the transcript. + const compactionAnchorId = useMemo(() => { + if (!summarizationEvent) return null; + const processedIds = new Set(processedMessages.map((d) => d.message.id)); + // Anchor before the first STILL-VISIBLE message at or after the cutoff, so + // a filtered boundary message (a tool result, or the transient summary + // leak itself) doesn't bump the block to the very end of the transcript. + for (let i = summarizationEvent.cutoffIndex; i < messages.length; i++) { + const id = messages[i]?.id; + if (id != null && processedIds.has(id)) return id; + } + return null; + }, [summarizationEvent, messages, processedMessages]); + const groupedTodos = { in_progress: todos.filter((t) => t.status === "in_progress"), pending: todos.filter((t) => t.status === "pending"), @@ -876,30 +905,45 @@ export const ChatInterface = React.memo( (u: any) => u.metadata?.message_id === data.message.id ); const isLastMessage = index === processedMessages.length - 1; + const showCompactionBefore = + compactionAnchorId === data.message.id; return ( - + + {showCompactionBefore && summarizationEvent && ( + + )} + + ); })} + {summarizationEvent && !compactionAnchorId && ( + + )} {askUserQuestions && (
( + ({ content, summarizedCount }) => { + const [open, setOpen] = useState(false); + const body = extractSummaryBody(content); + + return ( +
+
+
+ +
+
+ {open && ( +
+ +
+ )} +
+ ); + } +); + +CompactionSummary.displayName = "CompactionSummary"; diff --git a/src/app/hooks/useChat.ts b/src/app/hooks/useChat.ts index 285d529..4903314 100644 --- a/src/app/hooks/useChat.ts +++ b/src/app/hooks/useChat.ts @@ -12,6 +12,7 @@ import { extractSubAgentSteps, type SubAgentStep, } from "@/lib/subAgentActivity"; +import { parseSummarizationEvent } from "@/lib/summarization"; export type StateType = { messages: Message[]; @@ -25,6 +26,10 @@ export type StateType = { // Background async sub-agents (writing-agent / data-analysis-agent) this // conversation launched, keyed by task_id. Shape = deepagents' AsyncTask. async_tasks?: Record; + // Private state field set by the deepagents SummarizationMiddleware when the + // conversation is compacted. langgraph dev exposes it over the SDK; the UI + // surfaces it as a collapsible "Conversation compacted" block. + _summarization_event?: unknown; ui?: any; }; @@ -349,6 +354,9 @@ export function useChat({ files: stream.values.files ?? {}, email: stream.values.email, asyncTasks: stream.values.async_tasks ?? {}, + summarizationEvent: parseSummarizationEvent( + stream.values._summarization_event + ), ui: stream.values.ui, setFiles, messages, diff --git a/src/lib/summarization.ts b/src/lib/summarization.ts new file mode 100644 index 0000000..f65388e --- /dev/null +++ b/src/lib/summarization.ts @@ -0,0 +1,135 @@ +// Conversation-compaction (context summarization) support. +// +// When the backend (deepagents SummarizationMiddleware) compacts the +// conversation to stay under the context window, it does NOT rewrite the +// persisted `messages` — the full history stays intact. Instead it records the +// summary in a private state field `_summarization_event`: +// +// { +// cutoff_index: number, // summarized messages[0:cutoff_index] +// summary_message: HumanMessage, // additional_kwargs.lc_source="summarization" +// file_path: string | null, // offloaded full history (e.g. /conversation_history/.md) +// } +// +// langgraph dev exposes this underscore-prefixed key over the SDK (verified on +// the live backend), so the UI can surface the summary as a tidy collapsible +// block instead of letting it flash by mid-stream and vanish. +// +// The summary_message content is wrapped in one of two templates (deepagents +// `_build_new_messages_with_path`): +// +// "You are in the middle of a conversation that has been summarized.\n\n +// The full conversation history has been saved to {path} ...\n\n +// A condensed summary follows:\n\n\n{body}\n" +// +// or, when offload failed: +// +// "Here is a summary of the conversation to date:\n\n{body}" +// +// `{body}` is the LLM-authored summary (## SESSION INTENT / ## SUMMARY / +// ## ARTIFACTS / ## NEXT STEPS). + +import type { Message } from "@langchain/langgraph-sdk"; +import { extractStringFromMessageContent } from "@/app/utils/utils"; + +export interface SummarizationEvent { + cutoffIndex: number; + /** The raw summary message content (wrapper + body). */ + content: string; + /** Path where the full history was offloaded, or null. */ + filePath: string | null; +} + +/** + * The marker deepagents/langchain stamps on the summary HumanMessage's + * `additional_kwargs`. Most reliable signal when the summary lands in + * `messages` (langchain `before_model` path) rather than `_summarization_event`. + */ +function isSummarizationMarkerMessage(message: Message): boolean { + if (message.type !== "human") return false; + const ak = (message as { additional_kwargs?: Record }) + .additional_kwargs; + return ak?.["lc_source"] === "summarization"; +} + +/** + * Content-pattern fallback: true when the text looks like a compaction summary. + * Matches the wrapper templates OR the default summary section structure + * (so it also catches a transient mid-stream summary that carries no marker). + */ +export function isSummarizationContent( + text: string | null | undefined +): boolean { + if (!text) return false; + if ( + /\bconversation that has been summarized\b/.test(text) || + /\bHere is a summary of the conversation to date\b/.test(text) || + /[\s\S]*<\/summary>/.test(text) + ) { + return true; + } + // The summary always OPENS with "## SESSION INTENT" (first section of the + // DEFAULT_SUMMARY_PROMPT). Match on that lead header alone so detection fires + // the moment it streams in — requiring all sections (… NEXT STEPS) let the + // partially-streamed summary leak into the transcript until the last header + // arrived. "SESSION INTENT" is summarization-specific vocabulary; normal + // assistant answers don't emit it, so a single-header match is safe. + return /##\s+SESSION INTENT\b/.test(text); +} + +/** + * True when a message is a compaction summary. + * + * Human messages are matched ONLY by the `lc_source` marker (the persisted + * summary carries it) — never by content — so a user who happens to type + * "## SESSION INTENT" is not silently dropped from the transcript. The transient + * mid-stream summary leak is an AI message with no marker, so it is matched by + * content via the shared extractor (which also flattens array/content-block + * message shapes that a bare `typeof content === "string"` check would miss). + */ +export function isSummarizationMessage(message: Message): boolean { + if (message.type === "human") return isSummarizationMarkerMessage(message); + if (message.type !== "ai") return false; + return isSummarizationContent(extractStringFromMessageContent(message)); +} + +/** + * Strip the wrapper templates and return just the LLM-authored summary body + * (## SESSION INTENT …). Falls back to the trimmed input when no wrapper is + * recognized. + */ +export function extractSummaryBody(raw: string): string { + if (!raw) return ""; + // Prefer the explicit … region. + const tagged = raw.match(/\s*([\s\S]*?)\s*<\/summary>/); + if (tagged) return tagged[1].trim(); + // "Here is a summary of the conversation to date:\n\n{body}" + const lead = raw.match( + /Here is a summary of the conversation to date:\s*([\s\S]*)$/ + ); + if (lead) return lead[1].trim(); + return raw.trim(); +} + +/** + * Validate + normalize the raw `_summarization_event` from thread state. + * Returns null when absent or malformed. + */ +export function parseSummarizationEvent( + raw: unknown +): SummarizationEvent | null { + if (!raw || typeof raw !== "object") return null; + const ev = raw as Record; + const sm = ev["summary_message"] as + | { content?: unknown; additional_kwargs?: Record } + | undefined; + const content = typeof sm?.content === "string" ? sm.content : ""; + if (!content) return null; + const cutoff = ev["cutoff_index"]; + const filePath = ev["file_path"]; + return { + cutoffIndex: typeof cutoff === "number" && cutoff >= 0 ? cutoff : 0, + content, + filePath: typeof filePath === "string" ? filePath : null, + }; +}