feat(summarization): implement summarization feature with dedicated widget and event handling
This commit is contained in:
@@ -193,6 +193,7 @@ def run_textual_interactive(
|
||||
from .widgets import (
|
||||
LoadingWidget,
|
||||
ThinkingWidget,
|
||||
SummarizationWidget,
|
||||
AssistantMessage,
|
||||
ToolCallWidget,
|
||||
SubAgentWidget,
|
||||
@@ -482,6 +483,7 @@ def run_textual_interactive(
|
||||
state = StreamState()
|
||||
loading_removed = False
|
||||
thinking_w: ThinkingWidget | None = None
|
||||
summarization_w: SummarizationWidget | None = None
|
||||
assistant_w: AssistantMessage | None = None
|
||||
todo_w: TodoWidget | None = None
|
||||
tool_widgets: dict[str, ToolCallWidget] = {}
|
||||
@@ -655,7 +657,7 @@ def run_textual_interactive(
|
||||
|
||||
# -- Remove loading spinner on first content event --
|
||||
if not loading_removed and event_type in (
|
||||
"thinking", "text", "tool_call",
|
||||
"thinking", "text", "tool_call", "summarization",
|
||||
):
|
||||
await loading.cleanup()
|
||||
loading_removed = True
|
||||
@@ -667,6 +669,14 @@ def run_textual_interactive(
|
||||
await container.mount(thinking_w)
|
||||
thinking_w.append_text(event.get("content", ""))
|
||||
|
||||
elif event_type == "summarization":
|
||||
content = event.get("content", "")
|
||||
if content:
|
||||
if summarization_w is None:
|
||||
summarization_w = SummarizationWidget()
|
||||
await container.mount(summarization_w)
|
||||
summarization_w.set_content(content)
|
||||
|
||||
elif event_type == "text":
|
||||
if thinking_w is not None and thinking_w._is_active:
|
||||
thinking_w.finalize()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from .loading_widget import LoadingWidget
|
||||
from .thinking_widget import ThinkingWidget
|
||||
from .summarization_widget import SummarizationWidget
|
||||
from .assistant_message import AssistantMessage
|
||||
from .tool_call_widget import ToolCallWidget
|
||||
from .subagent_widget import SubAgentWidget
|
||||
@@ -15,6 +16,7 @@ from .thread_selector import ThreadPickerWidget
|
||||
__all__ = [
|
||||
"LoadingWidget",
|
||||
"ThinkingWidget",
|
||||
"SummarizationWidget",
|
||||
"AssistantMessage",
|
||||
"ToolCallWidget",
|
||||
"SubAgentWidget",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Summarization panel widget for context compression display.
|
||||
|
||||
Renders a Rich Panel showing that LangGraph's summarization middleware
|
||||
has compressed older conversation history. Yellow/amber border to
|
||||
distinguish from the blue thinking panel. Default collapsed; click to
|
||||
expand/collapse.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
from textual.events import Click
|
||||
from textual.widgets import Static
|
||||
|
||||
_MAX_COLLAPSED_CHARS = 80
|
||||
_MAX_EXPANDED_CHARS = 3000
|
||||
|
||||
|
||||
class SummarizationWidget(Static):
|
||||
"""Collapsible panel showing context summarization.
|
||||
|
||||
Unlike ThinkingWidget this is not streamed — the full text arrives
|
||||
in a single event. Defaults to collapsed with a one-line preview.
|
||||
|
||||
Usage::
|
||||
|
||||
w = SummarizationWidget()
|
||||
await container.mount(w)
|
||||
w.set_content("The conversation covered ...")
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
SummarizationWidget {
|
||||
height: auto;
|
||||
margin: 0 0 1 0;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("")
|
||||
self._content = ""
|
||||
self._collapsed = True
|
||||
|
||||
def _char_count_label(self) -> str:
|
||||
n = len(self._content)
|
||||
if n >= 1000:
|
||||
return f"{n / 1000:.1f}k chars"
|
||||
return f"{n:,} chars"
|
||||
|
||||
def _refresh_display(self) -> None:
|
||||
if not self._content:
|
||||
self.update("")
|
||||
return
|
||||
|
||||
if self._collapsed:
|
||||
title = f"Context Summarized ({self._char_count_label()})"
|
||||
first_line = self._content.strip().split("\n")[0].strip()
|
||||
if len(first_line) > _MAX_COLLAPSED_CHARS:
|
||||
first_line = first_line[:_MAX_COLLAPSED_CHARS - 3] + "\u2026"
|
||||
preview = Text(first_line, style="dim italic")
|
||||
preview.append(" [click to expand]", style="dim italic")
|
||||
body = preview
|
||||
else:
|
||||
title = f"Context Summarized ({self._char_count_label()})"
|
||||
display = self._content.rstrip()
|
||||
if len(display) > _MAX_EXPANDED_CHARS:
|
||||
half = _MAX_EXPANDED_CHARS // 2
|
||||
display = (
|
||||
display[:half]
|
||||
+ "\n\n... (truncated) ...\n\n"
|
||||
+ display[-half:]
|
||||
)
|
||||
body = Text(display, style="dim italic") if display else Text(
|
||||
"(empty)", style="dim"
|
||||
)
|
||||
|
||||
self.update(
|
||||
Panel(body, title=title, border_style="#f59e0b", padding=(0, 1))
|
||||
)
|
||||
|
||||
def set_content(self, text: str) -> None:
|
||||
"""Set the summarization text and refresh display."""
|
||||
self._content = text
|
||||
self._refresh_display()
|
||||
|
||||
def on_click(self, event: Click) -> None:
|
||||
"""Toggle collapsed/expanded state."""
|
||||
if self._content:
|
||||
self._collapsed = not self._collapsed
|
||||
self._refresh_display()
|
||||
@@ -374,6 +374,7 @@ def create_streaming_display(
|
||||
response_markdown: Any = None,
|
||||
total_input_tokens: int = 0,
|
||||
total_output_tokens: int = 0,
|
||||
summarization_text: str = "",
|
||||
) -> Any:
|
||||
"""Create Rich display layout for streaming output.
|
||||
|
||||
@@ -412,6 +413,18 @@ def create_streaming_display(
|
||||
padding=(0, 1),
|
||||
))
|
||||
|
||||
# Summarization panel (context was compressed by LangGraph middleware)
|
||||
if summarization_text:
|
||||
summary_display = summarization_text.rstrip()
|
||||
if len(summary_display) > 300:
|
||||
summary_display = summary_display[:300] + " ..."
|
||||
elements.append(Panel(
|
||||
Text(summary_display, style="dim italic"),
|
||||
title="Context Summarized",
|
||||
border_style="#f59e0b",
|
||||
padding=(0, 1),
|
||||
))
|
||||
|
||||
# Tool calls and results paired display
|
||||
# Collapse older completed tools to prevent overflow in Live mode
|
||||
# Task tool calls are ALWAYS visible (they represent sub-agent delegations)
|
||||
@@ -621,6 +634,16 @@ def display_final_results(
|
||||
border_style="blue",
|
||||
))
|
||||
|
||||
if state.summarization_text:
|
||||
summary_display = state.summarization_text.rstrip()
|
||||
if len(summary_display) > 500:
|
||||
summary_display = summary_display[:500] + " ..."
|
||||
console.print(Panel(
|
||||
Text(summary_display, style="dim italic"),
|
||||
title="Context Summarized",
|
||||
border_style="#f59e0b",
|
||||
))
|
||||
|
||||
if show_tools and state.tool_calls:
|
||||
shown_sa_names: set[str] = set()
|
||||
|
||||
@@ -1118,6 +1141,13 @@ async def _astream_to_console(
|
||||
dt = dt[:250] + "\n\u2026truncated\u2026\n" + dt[-250:]
|
||||
console.print(Panel(Text(dt, style="dim"), title="Thinking", border_style="blue"))
|
||||
|
||||
# Summarization
|
||||
if state.summarization_text:
|
||||
st = state.summarization_text.rstrip()
|
||||
if len(st) > 500:
|
||||
st = st[:500] + " ..."
|
||||
console.print(Panel(Text(st, style="dim italic"), title="Context Summarized", border_style="#f59e0b"))
|
||||
|
||||
# 1) Regular (non-task) tools — above Task List
|
||||
for i, tc in enumerate(state.tool_calls):
|
||||
if tc.get("name", "").lower() == "task":
|
||||
|
||||
@@ -109,6 +109,11 @@ class StreamEventEmitter:
|
||||
"review_configs": review_configs or [],
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def summarization(content: str) -> StreamEvent:
|
||||
"""Context summarization event."""
|
||||
return StreamEvent("summarization", {"type": "summarization", "content": content})
|
||||
|
||||
@staticmethod
|
||||
def error(message: str) -> StreamEvent:
|
||||
"""Error event."""
|
||||
|
||||
@@ -357,8 +357,13 @@ async def stream_agent_events(
|
||||
else:
|
||||
msg = data
|
||||
|
||||
# Filter summarization middleware synthetic messages
|
||||
# Emit summarization middleware messages as a dedicated event
|
||||
if isinstance(metadata, dict) and metadata.get("lc_source") == "summarization":
|
||||
content = ""
|
||||
if hasattr(msg, "content"):
|
||||
content = msg.content if isinstance(msg.content, str) else str(msg.content)
|
||||
if content:
|
||||
yield emitter.summarization(content).data
|
||||
continue
|
||||
|
||||
subagent = _get_subagent_name(namespace, metadata)
|
||||
|
||||
@@ -79,6 +79,7 @@ class StreamState:
|
||||
|
||||
def __init__(self):
|
||||
self.thinking_text = ""
|
||||
self.summarization_text = ""
|
||||
self.response_text = ""
|
||||
self.tool_calls = []
|
||||
self.tool_results = []
|
||||
@@ -259,6 +260,9 @@ class StreamState:
|
||||
elif event_type == "interrupt":
|
||||
self.pending_interrupt = event
|
||||
|
||||
elif event_type == "summarization":
|
||||
self.summarization_text = event.get("content", "")
|
||||
|
||||
elif event_type == "usage_stats":
|
||||
self.total_input_tokens += event.get("input_tokens", 0)
|
||||
self.total_output_tokens += event.get("output_tokens", 0)
|
||||
@@ -281,6 +285,7 @@ class StreamState:
|
||||
"""Get kwargs for create_streaming_display()."""
|
||||
return {
|
||||
"thinking_text": self.thinking_text,
|
||||
"summarization_text": self.summarization_text,
|
||||
"response_text": self.response_text,
|
||||
"latest_text": self.latest_text,
|
||||
"tool_calls": self.tool_calls,
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Tests for the summarization event pipeline and display widgets."""
|
||||
|
||||
from EvoScientist.stream.emitter import StreamEventEmitter
|
||||
from EvoScientist.stream.state import StreamState
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Emitter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSummarizationEmitter:
|
||||
"""StreamEventEmitter.summarization()."""
|
||||
|
||||
def test_event_type(self):
|
||||
ev = StreamEventEmitter.summarization("hello")
|
||||
assert ev.type == "summarization"
|
||||
|
||||
def test_event_data(self):
|
||||
ev = StreamEventEmitter.summarization("ctx compressed")
|
||||
assert ev.data["type"] == "summarization"
|
||||
assert ev.data["content"] == "ctx compressed"
|
||||
|
||||
def test_empty_content(self):
|
||||
ev = StreamEventEmitter.summarization("")
|
||||
assert ev.data["content"] == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# StreamState
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSummarizationState:
|
||||
"""StreamState handling of summarization events."""
|
||||
|
||||
def test_initial_state(self):
|
||||
state = StreamState()
|
||||
assert state.summarization_text == ""
|
||||
|
||||
def test_handle_summarization(self):
|
||||
state = StreamState()
|
||||
etype = state.handle_event({"type": "summarization", "content": "summary"})
|
||||
assert etype == "summarization"
|
||||
assert state.summarization_text == "summary"
|
||||
|
||||
def test_overwrites_previous(self):
|
||||
"""Each summarization replaces (not appends) the previous text."""
|
||||
state = StreamState()
|
||||
state.handle_event({"type": "summarization", "content": "first"})
|
||||
state.handle_event({"type": "summarization", "content": "second"})
|
||||
assert state.summarization_text == "second"
|
||||
|
||||
def test_get_display_args_includes_field(self):
|
||||
state = StreamState()
|
||||
state.handle_event({"type": "summarization", "content": "ctx"})
|
||||
args = state.get_display_args()
|
||||
assert "summarization_text" in args
|
||||
assert args["summarization_text"] == "ctx"
|
||||
|
||||
def test_does_not_affect_thinking(self):
|
||||
state = StreamState()
|
||||
state.handle_event({"type": "thinking", "content": "think"})
|
||||
state.handle_event({"type": "summarization", "content": "sum"})
|
||||
assert state.thinking_text == "think"
|
||||
assert state.summarization_text == "sum"
|
||||
|
||||
def test_does_not_affect_response(self):
|
||||
state = StreamState()
|
||||
state.handle_event({"type": "text", "content": "hello"})
|
||||
state.handle_event({"type": "summarization", "content": "sum"})
|
||||
assert state.response_text == "hello"
|
||||
assert state.summarization_text == "sum"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rich CLI display
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _render_group(group) -> str:
|
||||
"""Render a Rich Group to plain text for assertion checks."""
|
||||
from io import StringIO
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
buf = StringIO()
|
||||
console = Console(file=buf, width=120, force_terminal=True)
|
||||
console.print(group)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
class TestSummarizationRichDisplay:
|
||||
"""create_streaming_display() with summarization_text."""
|
||||
|
||||
def test_no_panel_when_empty(self):
|
||||
from EvoScientist.stream.display import create_streaming_display
|
||||
|
||||
group = create_streaming_display(summarization_text="")
|
||||
rendered = _render_group(group)
|
||||
assert "Context Summarized" not in rendered
|
||||
|
||||
def test_panel_rendered(self):
|
||||
from EvoScientist.stream.display import create_streaming_display
|
||||
|
||||
group = create_streaming_display(
|
||||
summarization_text="The conversation was about ML.",
|
||||
response_text="ok",
|
||||
)
|
||||
rendered = _render_group(group)
|
||||
assert "Context Summarized" in rendered
|
||||
|
||||
def test_long_text_truncated(self):
|
||||
from EvoScientist.stream.display import create_streaming_display
|
||||
|
||||
long_text = "x" * 500
|
||||
group = create_streaming_display(
|
||||
summarization_text=long_text,
|
||||
response_text="ok",
|
||||
)
|
||||
rendered = _render_group(group)
|
||||
assert "..." in rendered
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TUI SummarizationWidget
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSummarizationWidget:
|
||||
"""SummarizationWidget (Textual TUI)."""
|
||||
|
||||
def test_init_collapsed(self):
|
||||
from EvoScientist.cli.widgets.summarization_widget import SummarizationWidget
|
||||
|
||||
w = SummarizationWidget()
|
||||
assert w._collapsed is True
|
||||
assert w._content == ""
|
||||
|
||||
def test_set_content(self):
|
||||
from EvoScientist.cli.widgets.summarization_widget import SummarizationWidget
|
||||
|
||||
w = SummarizationWidget()
|
||||
w._content = "test content"
|
||||
assert w._content == "test content"
|
||||
|
||||
def test_char_count_label_small(self):
|
||||
from EvoScientist.cli.widgets.summarization_widget import SummarizationWidget
|
||||
|
||||
w = SummarizationWidget()
|
||||
w._content = "hello"
|
||||
assert w._char_count_label() == "5 chars"
|
||||
|
||||
def test_char_count_label_large(self):
|
||||
from EvoScientist.cli.widgets.summarization_widget import SummarizationWidget
|
||||
|
||||
w = SummarizationWidget()
|
||||
w._content = "x" * 2500
|
||||
assert w._char_count_label() == "2.5k chars"
|
||||
|
||||
def test_toggle_collapsed(self):
|
||||
from EvoScientist.cli.widgets.summarization_widget import SummarizationWidget
|
||||
|
||||
w = SummarizationWidget()
|
||||
assert w._collapsed is True
|
||||
w._collapsed = not w._collapsed
|
||||
assert w._collapsed is False
|
||||
w._collapsed = not w._collapsed
|
||||
assert w._collapsed is True
|
||||
Reference in New Issue
Block a user