feat: add /compact command to summarize conversation and free context

This commit is contained in:
X-iZhang
2026-03-14 20:09:38 +00:00
parent 46a1de9dd6
commit 21a875e026
4 changed files with 580 additions and 0 deletions
+248
View File
@@ -90,6 +90,254 @@ def channel_setup():
console.print("[dim]No changes made.[/dim]")
# =============================================================================
# Compact helper
# =============================================================================
class CompactResult:
"""Structured result from compact_conversation.
Attributes:
status: "noop" (nothing to compact), "ok" (compacted), or "error".
message: Short human-readable message (used as fallback / TUI text).
messages_compacted: Number of messages summarized (0 for noop/error).
messages_kept: Number of messages unchanged.
tokens_before: Total tokens before compaction.
tokens_after: Total tokens after compaction.
tokens_summarized: Tokens in the summarized portion (before).
tokens_summary: Tokens in the summary message (after).
pct_decrease: Percentage decrease.
"""
__slots__ = (
"status", "message",
"messages_compacted", "messages_kept",
"tokens_before", "tokens_after",
"tokens_summarized", "tokens_summary",
"pct_decrease",
)
def __init__(
self,
status: str,
message: str,
*,
messages_compacted: int = 0,
messages_kept: int = 0,
tokens_before: int = 0,
tokens_after: int = 0,
tokens_summarized: int = 0,
tokens_summary: int = 0,
pct_decrease: int = 0,
):
self.status = status
self.message = message
self.messages_compacted = messages_compacted
self.messages_kept = messages_kept
self.tokens_before = tokens_before
self.tokens_after = tokens_after
self.tokens_summarized = tokens_summarized
self.tokens_summary = tokens_summary
self.pct_decrease = pct_decrease
def __str__(self) -> str:
return self.message
def render_compact_result(result: CompactResult): # -> rich.text.Text
"""Render a CompactResult as styled Rich Text.
Uses the same visual language as the token usage display:
cyan for numbers, green for savings, dim for labels.
"""
from rich.text import Text
output = Text()
if result.status == "noop":
output.append("○ ", style="dim")
output.append("Nothing to compact", style="dim")
if result.tokens_before > 0:
output.append(" — conversation is ~", style="dim")
output.append(f"{result.tokens_before:,}", style="cyan")
output.append(" tokens, within retention budget", style="dim")
elif result.message:
# Extract reason from message (e.g. "no messages")
output.append(f" — {result.message.split('—')[-1].strip()}" if "—" in result.message else "", style="dim")
return output
if result.status == "error":
output.append("✗ ", style="red")
output.append(result.message, style="red")
return output
# status == "ok"
output.append("✓ ", style="green")
output.append("Compacted ", style="dim")
output.append(f"{result.messages_compacted}", style="bold")
output.append(" messages", style="dim")
output.append(" [", style="dim")
output.append(f"{result.tokens_before:,}", style="cyan")
output.append(" → ", style="dim")
output.append(f"{result.tokens_after:,}", style="green")
output.append(" tokens", style="dim")
output.append(f" ↓{result.pct_decrease}%", style="green bold")
output.append("]", style="dim")
# Second line: detail breakdown
output.append("\n ", style="")
output.append("Summarized: ", style="dim")
output.append(f"{result.tokens_summarized:,}", style="cyan")
output.append(" → ", style="dim")
output.append(f"{result.tokens_summary:,}", style="green")
output.append(" │ ", style="dim")
output.append("Kept: ", style="dim")
output.append(f"{result.messages_kept}", style="cyan")
output.append(" messages unchanged", style="dim")
return output
async def compact_conversation(agent: Any, thread_id: str | None) -> CompactResult:
"""Compact the conversation by summarizing old messages.
Reads the agent's checkpointed state, creates a temporary
``SummarizationMiddleware``, generates a summary, and writes
the compacted state back via ``aupdate_state``.
Returns a structured ``CompactResult``.
"""
if not agent or not thread_id:
return CompactResult("noop", "Nothing to compact — start a conversation first.")
from langchain_core.messages.utils import count_tokens_approximately
config = {"configurable": {"thread_id": thread_id}}
try:
state_snapshot = await agent.aget_state(config)
except Exception as exc:
return CompactResult("error", f"Failed to read state: {exc}")
messages = state_snapshot.values.get("messages", [])
if not messages:
return CompactResult("noop", "Nothing to compact — no messages in conversation.")
from ..EvoScientist import _ensure_chat_model, _get_default_backend
from deepagents.middleware.summarization import (
SummarizationEvent,
SummarizationMiddleware,
compute_summarization_defaults,
)
try:
model = _ensure_chat_model()
except Exception as exc:
return CompactResult("error", f"Compaction requires a working model configuration: {exc}")
backend = _get_default_backend()
defaults = compute_summarization_defaults(model)
middleware = SummarizationMiddleware(
model=model,
backend=backend,
keep=defaults["keep"],
trim_tokens_to_summarize=None,
)
# Rebuild effective message list accounting for prior compaction
event = state_snapshot.values.get("_summarization_event")
effective = middleware._apply_event_to_messages(messages, event)
cutoff = middleware._determine_cutoff_index(effective)
if cutoff == 0:
conv_tokens = count_tokens_approximately(effective)
return CompactResult(
"noop",
f"Nothing to compact — conversation (~{conv_tokens:,} tokens) "
f"is within the retention budget.",
tokens_before=conv_tokens,
)
to_summarize, to_keep = middleware._partition_messages(effective, cutoff)
tokens_summarized = count_tokens_approximately(to_summarize)
tokens_kept = count_tokens_approximately(to_keep)
tokens_before = tokens_summarized + tokens_kept
# Skip if savings would be negligible — compacting ≤2 messages with
# <2% of total tokens prevents the infinite 1-message-at-a-time loop
# that occurs when the conversation sits just above the keep budget.
_MIN_COMPACT_MESSAGES = 3
_MIN_COMPACT_TOKEN_FRACTION = 0.02
if (
len(to_summarize) < _MIN_COMPACT_MESSAGES
and tokens_summarized < tokens_before * _MIN_COMPACT_TOKEN_FRACTION
):
return CompactResult(
"noop",
f"Nothing to compact — only {len(to_summarize)} message(s) "
f"({tokens_summarized:,} tokens) would be summarized, "
f"not worth the overhead.",
tokens_before=tokens_before,
)
# Generate summary (LLM call)
summary = await middleware._acreate_summary(to_summarize)
# Offload old messages to backend
file_path: str | None = None
try:
file_path = await middleware._aoffload_to_backend(backend, to_summarize)
except Exception:
pass # non-fatal — proceed without offloaded history
summary_msg = middleware._build_new_messages_with_path(summary, file_path)[0]
# Compute token savings
tokens_summary = count_tokens_approximately([summary_msg])
tokens_after = tokens_summary + tokens_kept
pct = (
round((tokens_before - tokens_after) / tokens_before * 100)
if tokens_before > 0
else 0
)
# Append savings note to summary message for model awareness
savings_note = (
f"\n\n{len(to_summarize)} messages were compacted "
f"({tokens_summarized:,} → {tokens_summary:,} tokens). "
f"Total context: {tokens_before:,} → {tokens_after:,} tokens "
f"({pct}% decrease), "
f"{len(to_keep)} messages unchanged."
)
summary_msg.content += savings_note
state_cutoff = middleware._compute_state_cutoff(event, cutoff)
new_event: SummarizationEvent = {
"cutoff_index": state_cutoff,
"summary_message": summary_msg,
"file_path": file_path,
}
await agent.aupdate_state(config, {"_summarization_event": new_event})
return CompactResult(
"ok",
f"Compacted {len(to_summarize)} messages "
f"({tokens_before:,} → {tokens_after:,} tokens, {pct}% decrease)",
messages_compacted=len(to_summarize),
messages_kept=len(to_keep),
tokens_before=tokens_before,
tokens_after=tokens_after,
tokens_summarized=tokens_summarized,
tokens_summary=tokens_summary,
pct_decrease=pct,
)
# =============================================================================
# Serve helpers
# =============================================================================
+11
View File
@@ -112,6 +112,7 @@ _SLASH_COMMANDS = [
("/uninstall-skill", "Remove an installed skill"),
("/mcp", "Manage MCP servers"),
("/channel", "Configure messaging channels"),
("/compact", "Compact conversation to free context"),
("/exit", "Quit EvoScientist"),
]
@@ -694,6 +695,16 @@ def cmd_interactive(
)
continue
if user_input.lower() == "/compact":
from .commands import compact_conversation, render_compact_result
with console.status("[cyan]Compacting conversation...[/cyan]"):
result = await compact_conversation(
agent=state["agent"],
thread_id=state["thread_id"],
)
console.print(render_compact_result(result))
continue
# Stream agent response with metadata for persistence
console.print()
meta = build_metadata(state["workspace_dir"], model)
+11
View File
@@ -59,6 +59,7 @@ _TUI_SLASH_COMMANDS = [
("/uninstall-skill", "Remove an installed skill"),
("/mcp", "Manage MCP servers"),
("/channel", "Configure messaging channels"),
("/compact", "Compact conversation to free context"),
("/help", "Show available commands"),
("/exit", "Quit EvoScientist"),
]
@@ -1528,6 +1529,16 @@ def run_textual_interactive(
self._cmd_channel(arg)
return
if cmd == "/compact":
from .commands import compact_conversation, render_compact_result
self._append_system("Compacting conversation...")
result = await compact_conversation(
agent=self._agent,
thread_id=self._conversation_tid,
)
self._mount_renderable(render_compact_result(result))
return
self._append_system(f"Unknown command: {command}", style="yellow")
async def _resolve_thread_id(self, prefix: str) -> str | None:
+310
View File
@@ -0,0 +1,310 @@
"""Tests for the /compact command (compact_conversation helper)."""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@pytest.fixture
def _run():
"""Helper to run async tests."""
loop = asyncio.new_event_loop()
yield loop.run_until_complete
loop.close()
class TestCompactGuards:
"""Guard conditions that return early without touching the middleware."""
def test_no_agent(self, _run):
from EvoScientist.cli.commands import compact_conversation
result = _run(compact_conversation(agent=None, thread_id="abc"))
assert result.status == "noop"
assert "Nothing to compact" in result.message
def test_no_thread_id(self, _run):
from EvoScientist.cli.commands import compact_conversation
result = _run(compact_conversation(agent=MagicMock(), thread_id=None))
assert result.status == "noop"
assert "Nothing to compact" in result.message
def test_empty_messages(self, _run):
from EvoScientist.cli.commands import compact_conversation
agent = MagicMock()
snapshot = SimpleNamespace(values={"messages": []})
agent.aget_state = AsyncMock(return_value=snapshot)
result = _run(compact_conversation(agent=agent, thread_id="tid-1"))
assert result.status == "noop"
assert "no messages" in result.message
def test_state_read_failure(self, _run):
from EvoScientist.cli.commands import compact_conversation
agent = MagicMock()
agent.aget_state = AsyncMock(side_effect=RuntimeError("DB gone"))
result = _run(compact_conversation(agent=agent, thread_id="tid-1"))
assert result.status == "error"
assert "Failed to read state" in result.message
class TestCompactCutoffZero:
"""When cutoff == 0, conversation is within retention budget."""
def test_nothing_to_compact_short_conversation(self, _run):
from EvoScientist.cli.commands import compact_conversation
agent = MagicMock()
msgs = [MagicMock() for _ in range(3)]
snapshot = SimpleNamespace(values={"messages": msgs})
agent.aget_state = AsyncMock(return_value=snapshot)
mock_middleware_inst = MagicMock()
mock_middleware_inst._apply_event_to_messages.return_value = msgs
mock_middleware_inst._determine_cutoff_index.return_value = 0
mock_middleware_cls = MagicMock(return_value=mock_middleware_inst)
with (
patch("EvoScientist.EvoScientist._ensure_chat_model", return_value=MagicMock()),
patch("EvoScientist.EvoScientist._get_default_backend", return_value=MagicMock()),
patch("deepagents.middleware.summarization.SummarizationMiddleware", mock_middleware_cls),
patch("deepagents.middleware.summarization.compute_summarization_defaults", return_value={"keep": ("messages", 6)}),
patch("langchain_core.messages.utils.count_tokens_approximately", return_value=500),
):
result = _run(compact_conversation(agent=agent, thread_id="tid-1"))
assert result.status == "noop"
assert "within the retention budget" in result.message
assert result.tokens_before == 500
class TestCompactNegligibleSavings:
"""When cutoff > 0 but savings are too small to be worth it."""
def test_skip_when_few_messages_and_low_tokens(self, _run):
from EvoScientist.cli.commands import compact_conversation
agent = MagicMock()
msgs = [MagicMock() for _ in range(15)]
snapshot = SimpleNamespace(values={"messages": msgs, "_summarization_event": None})
agent.aget_state = AsyncMock(return_value=snapshot)
mock_middleware_inst = MagicMock()
mock_middleware_inst._apply_event_to_messages.return_value = msgs
mock_middleware_inst._determine_cutoff_index.return_value = 1
# 1 message to summarize (200 tokens), 14 to keep (22000 tokens)
mock_middleware_inst._partition_messages.return_value = (msgs[:1], msgs[1:])
mock_middleware_cls = MagicMock(return_value=mock_middleware_inst)
# to_summarize=200, to_keep=22000 → total=22200, 200/22200 < 2%
token_values = iter([200, 22000])
with (
patch("EvoScientist.EvoScientist._ensure_chat_model", return_value=MagicMock()),
patch("EvoScientist.EvoScientist._get_default_backend", return_value=MagicMock()),
patch("deepagents.middleware.summarization.SummarizationMiddleware", mock_middleware_cls),
patch("deepagents.middleware.summarization.compute_summarization_defaults", return_value={"keep": ("messages", 6)}),
patch("langchain_core.messages.utils.count_tokens_approximately", side_effect=lambda x: next(token_values)),
):
result = _run(compact_conversation(agent=agent, thread_id="tid-1"))
assert result.status == "noop"
assert "not worth" in result.message
# No LLM call should have been made
mock_middleware_inst._acreate_summary.assert_not_called()
def test_still_compacts_when_few_messages_but_high_tokens(self, _run):
"""2 messages but they account for >2% of tokens — should compact."""
from EvoScientist.cli.commands import compact_conversation
from langchain_core.messages import HumanMessage
agent = MagicMock()
msgs = [MagicMock() for _ in range(10)]
snapshot = SimpleNamespace(values={"messages": msgs, "_summarization_event": None})
agent.aget_state = AsyncMock(return_value=snapshot)
agent.aupdate_state = AsyncMock()
summary_msg = HumanMessage(content="Summary")
mock_middleware_inst = MagicMock()
mock_middleware_inst._apply_event_to_messages.return_value = msgs
mock_middleware_inst._determine_cutoff_index.return_value = 2
mock_middleware_inst._partition_messages.return_value = (msgs[:2], msgs[2:])
mock_middleware_inst._acreate_summary = AsyncMock(return_value="Summary")
mock_middleware_inst._aoffload_to_backend = AsyncMock(return_value=None)
mock_middleware_inst._build_new_messages_with_path.return_value = [summary_msg]
mock_middleware_inst._compute_state_cutoff.return_value = 2
mock_middleware_cls = MagicMock(return_value=mock_middleware_inst)
# to_summarize=5000, to_keep=15000 → total=20000, 5000/20000=25% > 2%
token_values = iter([5000, 15000, 500])
with (
patch("EvoScientist.EvoScientist._ensure_chat_model", return_value=MagicMock()),
patch("EvoScientist.EvoScientist._get_default_backend", return_value=MagicMock()),
patch("deepagents.middleware.summarization.SummarizationMiddleware", mock_middleware_cls),
patch("deepagents.middleware.summarization.compute_summarization_defaults", return_value={"keep": ("messages", 6)}),
patch("langchain_core.messages.utils.count_tokens_approximately", side_effect=lambda x: next(token_values)),
):
result = _run(compact_conversation(agent=agent, thread_id="tid-1"))
assert result.status == "ok"
agent.aupdate_state.assert_awaited_once()
class TestCompactSuccess:
"""Normal compaction flow."""
def test_successful_compaction(self, _run):
from EvoScientist.cli.commands import compact_conversation
from langchain_core.messages import HumanMessage
agent = MagicMock()
msgs = [MagicMock() for _ in range(20)]
snapshot = SimpleNamespace(values={"messages": msgs, "_summarization_event": None})
agent.aget_state = AsyncMock(return_value=snapshot)
agent.aupdate_state = AsyncMock()
summary_msg = HumanMessage(content="Summary of conversation")
to_summarize = msgs[:15]
to_keep = msgs[15:]
mock_middleware_inst = MagicMock()
mock_middleware_inst._apply_event_to_messages.return_value = msgs
mock_middleware_inst._determine_cutoff_index.return_value = 15
mock_middleware_inst._partition_messages.return_value = (to_summarize, to_keep)
mock_middleware_inst._acreate_summary = AsyncMock(return_value="Summary text")
mock_middleware_inst._aoffload_to_backend = AsyncMock(return_value="/conversation_history/tid.md")
mock_middleware_inst._build_new_messages_with_path.return_value = [summary_msg]
mock_middleware_inst._compute_state_cutoff.return_value = 15
mock_middleware_cls = MagicMock(return_value=mock_middleware_inst)
# count_tokens_approximately returns different values per call
token_values = iter([5000, 1000, 200])
with (
patch("EvoScientist.EvoScientist._ensure_chat_model", return_value=MagicMock()),
patch("EvoScientist.EvoScientist._get_default_backend", return_value=MagicMock()),
patch("deepagents.middleware.summarization.SummarizationMiddleware", mock_middleware_cls),
patch("deepagents.middleware.summarization.compute_summarization_defaults", return_value={"keep": ("messages", 6)}),
patch("langchain_core.messages.utils.count_tokens_approximately", side_effect=lambda x: next(token_values)),
):
result = _run(compact_conversation(agent=agent, thread_id="tid-1"))
assert result.status == "ok"
assert result.messages_compacted == 15
assert result.messages_kept == 5
assert result.tokens_before == 6000
assert result.tokens_after == 1200
assert result.pct_decrease == 80
agent.aupdate_state.assert_awaited_once()
# Verify the event structure passed to aupdate_state
call_args = agent.aupdate_state.call_args
event_data = call_args[0][1]
assert "_summarization_event" in event_data
assert event_data["_summarization_event"]["cutoff_index"] == 15
def test_offload_failure_non_fatal(self, _run):
"""Offload failure should not prevent compaction."""
from EvoScientist.cli.commands import compact_conversation
from langchain_core.messages import HumanMessage
agent = MagicMock()
msgs = [MagicMock() for _ in range(10)]
snapshot = SimpleNamespace(values={"messages": msgs, "_summarization_event": None})
agent.aget_state = AsyncMock(return_value=snapshot)
agent.aupdate_state = AsyncMock()
summary_msg = HumanMessage(content="Summary")
mock_middleware_inst = MagicMock()
mock_middleware_inst._apply_event_to_messages.return_value = msgs
mock_middleware_inst._determine_cutoff_index.return_value = 7
mock_middleware_inst._partition_messages.return_value = (msgs[:7], msgs[7:])
mock_middleware_inst._acreate_summary = AsyncMock(return_value="Summary")
mock_middleware_inst._aoffload_to_backend = AsyncMock(side_effect=RuntimeError("write failed"))
mock_middleware_inst._build_new_messages_with_path.return_value = [summary_msg]
mock_middleware_inst._compute_state_cutoff.return_value = 7
mock_middleware_cls = MagicMock(return_value=mock_middleware_inst)
with (
patch("EvoScientist.EvoScientist._ensure_chat_model", return_value=MagicMock()),
patch("EvoScientist.EvoScientist._get_default_backend", return_value=MagicMock()),
patch("deepagents.middleware.summarization.SummarizationMiddleware", mock_middleware_cls),
patch("deepagents.middleware.summarization.compute_summarization_defaults", return_value={"keep": ("messages", 6)}),
patch("langchain_core.messages.utils.count_tokens_approximately", return_value=1000),
):
result = _run(compact_conversation(agent=agent, thread_id="tid-1"))
assert result.status == "ok"
agent.aupdate_state.assert_awaited_once()
# file_path should be None in the event
event_data = agent.aupdate_state.call_args[0][1]
assert event_data["_summarization_event"]["file_path"] is None
class TestRenderCompactResult:
"""Test the Rich rendering of CompactResult."""
def test_render_noop(self):
from EvoScientist.cli.commands import CompactResult, render_compact_result
result = CompactResult("noop", "Nothing to compact", tokens_before=500)
text = render_compact_result(result)
plain = text.plain
assert "Nothing to compact" in plain
assert "500" in plain
def test_render_noop_no_tokens(self):
from EvoScientist.cli.commands import CompactResult, render_compact_result
result = CompactResult("noop", "Nothing to compact — no messages in conversation.")
text = render_compact_result(result)
assert "Nothing to compact" in text.plain
def test_render_error(self):
from EvoScientist.cli.commands import CompactResult, render_compact_result
result = CompactResult("error", "Failed to read state: DB gone")
text = render_compact_result(result)
assert "Failed to read state" in text.plain
def test_render_ok(self):
from EvoScientist.cli.commands import CompactResult, render_compact_result
result = CompactResult(
"ok", "Compacted",
messages_compacted=15,
messages_kept=5,
tokens_before=6000,
tokens_after=1200,
tokens_summarized=5000,
tokens_summary=200,
pct_decrease=80,
)
text = render_compact_result(result)
plain = text.plain
assert "15" in plain
assert "6,000" in plain
assert "1,200" in plain
assert "80%" in plain
assert "5 messages unchanged" in plain
def test_str_fallback(self):
from EvoScientist.cli.commands import CompactResult
result = CompactResult("ok", "hello world")
assert str(result) == "hello world"