feat: update version to 0.0.8 and implement conversation compaction summary feature

This commit is contained in:
Xi Zhang
2026-06-10 12:23:50 +01:00
parent 13ae6fcf54
commit 4e7901018f
6 changed files with 279 additions and 24 deletions
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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 <zacharyzhang2022@gmail.com>",
"type": "module",
+65 -21
View File
@@ -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<ChatInterfaceProps>(
resumeInterrupt,
subAgentActivity,
asyncTasks,
summarizationEvent,
} = useChatContext();
// Count of background async sub-agents (writing / data-analysis) still
@@ -660,6 +663,13 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
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<ChatInterfaceProps>(
});
}, [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<ChatInterfaceProps>(
(u: any) => u.metadata?.message_id === data.message.id
);
const isLastMessage = index === processedMessages.length - 1;
const showCompactionBefore =
compactionAnchorId === data.message.id;
return (
<ChatMessage
key={data.message.id}
message={data.message}
toolCalls={data.toolCalls}
isLoading={isLoading}
actionRequests={
isLastMessage ? actionRequests : undefined
}
submittedActionRequestKeys={submittedActionRequestKeys}
onActionRequestSubmitted={markActionRequestSubmitted}
reviewConfigsMap={
isLastMessage ? reviewConfigsMap : undefined
}
ui={messageUi}
stream={stream}
onResumeInterrupt={resumeInterrupt}
graphId={assistant?.graph_id}
onEditMessage={handleEditMessage}
autoApprove={autoApprove}
subAgentSteps={subAgentSteps}
/>
<React.Fragment key={data.message.id}>
{showCompactionBefore && summarizationEvent && (
<CompactionSummary
content={summarizationEvent.content}
summarizedCount={summarizationEvent.cutoffIndex}
/>
)}
<ChatMessage
message={data.message}
toolCalls={data.toolCalls}
isLoading={isLoading}
actionRequests={
isLastMessage ? actionRequests : undefined
}
submittedActionRequestKeys={submittedActionRequestKeys}
onActionRequestSubmitted={markActionRequestSubmitted}
reviewConfigsMap={
isLastMessage ? reviewConfigsMap : undefined
}
ui={messageUi}
stream={stream}
onResumeInterrupt={resumeInterrupt}
graphId={assistant?.graph_id}
onEditMessage={handleEditMessage}
autoApprove={autoApprove}
subAgentSteps={subAgentSteps}
/>
</React.Fragment>
);
})}
{summarizationEvent && !compactionAnchorId && (
<CompactionSummary
content={summarizationEvent.content}
summarizedCount={summarizationEvent.cutoffIndex}
/>
)}
{askUserQuestions && (
<div className="mt-4">
<AskUserInterrupt
+68
View File
@@ -0,0 +1,68 @@
"use client";
import React, { useState } from "react";
import { ChevronRight, Layers } from "lucide-react";
import { MarkdownContent } from "@/app/components/MarkdownContent";
import { extractSummaryBody } from "@/lib/summarization";
import { cn } from "@/lib/utils";
interface CompactionSummaryProps {
/** Raw summary message content (wrapper + body). */
content: string;
/** Number of earlier messages folded into this summary, if known. */
summarizedCount?: number;
}
/**
* Renders a conversation-compaction summary as a collapsible block, mirroring
* the "Thinking" disclosure in ChatMessage. Collapsed by default so the (often
* large) summary doesn't dominate the transcript — the backend keeps the full
* history intact, this is just the context the agent was handed after compaction.
*/
export const CompactionSummary = React.memo<CompactionSummaryProps>(
({ content, summarizedCount }) => {
const [open, setOpen] = useState(false);
const body = extractSummaryBody(content);
return (
<div className="my-3 w-full">
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-border" />
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="inline-flex items-center gap-1.5 rounded-full border border-border bg-muted/40 px-3 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
>
<Layers
className="size-3.5 text-[var(--brand)]"
aria-hidden="true"
/>
Conversation compacted
{typeof summarizedCount === "number" && summarizedCount > 0 && (
<span className="text-muted-foreground/70">
· {summarizedCount} earlier{" "}
{summarizedCount === 1 ? "message" : "messages"} summarized
</span>
)}
<ChevronRight
className={cn(
"size-3.5 transition-transform",
open && "rotate-90"
)}
aria-hidden="true"
/>
</button>
<div className="h-px flex-1 bg-border" />
</div>
{open && (
<div className="mt-2 overflow-hidden rounded-md border border-border bg-muted/20 px-4 py-3">
<MarkdownContent content={body} />
</div>
)}
</div>
);
}
);
CompactionSummary.displayName = "CompactionSummary";
+8
View File
@@ -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<string, unknown>;
// 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,
+135
View File
@@ -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/<tid>.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<summary>\n{body}\n</summary>"
//
// 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<string, unknown> })
.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) ||
/<summary>[\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 <summary>…</summary> region.
const tagged = raw.match(/<summary>\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<string, unknown>;
const sm = ev["summary_message"] as
| { content?: unknown; additional_kwargs?: Record<string, unknown> }
| 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,
};
}