diff --git a/EvoScientist/cli/commands.py b/EvoScientist/cli/commands.py index b94210b..6bf5741 100644 --- a/EvoScientist/cli/commands.py +++ b/EvoScientist/cli/commands.py @@ -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 # ============================================================================= diff --git a/EvoScientist/cli/interactive.py b/EvoScientist/cli/interactive.py index 9328dae..b364151 100644 --- a/EvoScientist/cli/interactive.py +++ b/EvoScientist/cli/interactive.py @@ -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) diff --git a/EvoScientist/cli/tui_interactive.py b/EvoScientist/cli/tui_interactive.py index 990fc70..7dc28b9 100644 --- a/EvoScientist/cli/tui_interactive.py +++ b/EvoScientist/cli/tui_interactive.py @@ -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: diff --git a/tests/test_compact_command.py b/tests/test_compact_command.py new file mode 100644 index 0000000..f922d5c --- /dev/null +++ b/tests/test_compact_command.py @@ -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"