From 9285c6dad89867c18be205a9408fe0628742e87c Mon Sep 17 00:00:00 2001 From: dinos Date: Tue, 2 Jun 2026 19:21:14 +0200 Subject: [PATCH] Migrate memory middleware to profile files (#253) * feat(memory): migrate to profile memory files * chore(stream): read profile headings from templates * fix(display): keep assistant responses if response_text has started * fix(memory): do not treat failed bootstraps as profile creation * chore(memory): unlink blank legacy memory * fix(memory): resolve project_id once * fix(memory): preserve unreadable profile files * chore(tui): render streamed narration inline with tool timeline Update the TUI streaming timeline so assistant text emitted before or between tool calls is rendered inline where it occurs, rather than being kept as a single answer bubble above or below the tools. If the model begins an assistant response and then emits another tool call, the provisional response is converted into inline narration before that tool. The final assistant message then renders only the remaining response suffix, avoiding duplicate text in the completed transcript. Stop/cancel handling now preserves any active inline narration, appends the visible stopped marker only to the remaining displayed segment, and still returns the full normalized stopped response for channel callers. Completed tools continue to collapse while long runs are active, but expand again when the turn reaches a final state so the completed transcript shows the full tool timeline. * fix(stream): preserve narration around tool timelines Keep assistant narration attached to the tool call that follows it instead of folding all streamed text into the final answer block. Track narrated response segments in stream state, render them before their corresponding regular or task tool entries, and keep final answers limited to the response suffix that has not already been shown inline. Preserve narration across normal completion, stop/error final frames, sub-agent task calls, and collapsed live tool summaries. Add regression coverage for pending tools, completed tools, sub-agent task delegations, collapsed completed/running tool summaries, and final stop frames. * fix(tui): finalize inline narration transitions * test(memory): use canonical project id helper --- EvoScientist/EvoScientist.py | 10 +- EvoScientist/cli/tui_interactive.py | 259 ++++-- EvoScientist/middleware/__init__.py | 4 - EvoScientist/middleware/memory.py | 1088 ++++++++--------------- EvoScientist/stream/display.py | 266 ++++-- EvoScientist/stream/state.py | 39 +- EvoScientist/stream/utils.py | 32 +- EvoScientist/subagents/_factory.py | 10 +- tests/test_memory_merge.py | 73 -- tests/test_profile_memory_middleware.py | 338 +++++++ tests/test_stream_display.py | 425 ++++++++- tests/test_stream_state.py | 57 +- tests/test_stream_utils.py | 47 +- tests/test_tui_widgets.py | 27 +- 14 files changed, 1596 insertions(+), 1079 deletions(-) delete mode 100644 tests/test_memory_merge.py create mode 100644 tests/test_profile_memory_middleware.py diff --git a/EvoScientist/EvoScientist.py b/EvoScientist/EvoScientist.py index 9741259..ba5016f 100644 --- a/EvoScientist/EvoScientist.py +++ b/EvoScientist/EvoScientist.py @@ -449,7 +449,11 @@ def _get_default_backend(): ) -def _get_default_middleware(*, for_async_subagent: bool = False): +def _get_default_middleware( + *, + for_async_subagent: bool = False, + workspace_dir: str | Path | None = None, +): """Build the default middleware list. Args: @@ -492,7 +496,7 @@ def _get_default_middleware(*, for_async_subagent: bool = False): ContextOverflowMapperMiddleware(), ToolErrorHandlerMiddleware(), *create_tool_selector_middleware(model=model), - create_memory_middleware(memory_dir, extraction_model=model), + create_memory_middleware(memory_dir, workspace_dir=workspace_dir), ] if cfg.enable_ask_user and not cfg.auto_mode and not for_async_subagent: @@ -669,7 +673,7 @@ def create_cli_agent( # Delegate middleware construction to the single source of truth so the # CLI agent never drifts from the default chain. Anything CLI-specific # (e.g. ``HumanInTheLoopMiddleware``) is appended below. - mw: list[AgentMiddleware] = _get_default_middleware() + mw: list[AgentMiddleware] = _get_default_middleware(workspace_dir=workspace_dir) # HITL on main agent only — passing `interrupt_on=` to create_deep_agent # would propagate it to every subagent, breaking parallel execute calls diff --git a/EvoScientist/cli/tui_interactive.py b/EvoScientist/cli/tui_interactive.py index ad4d972..8d294f3 100644 --- a/EvoScientist/cli/tui_interactive.py +++ b/EvoScientist/cli/tui_interactive.py @@ -12,6 +12,7 @@ import queue import random import sys from collections.abc import Callable +from dataclasses import dataclass from datetime import datetime from typing import Any, ClassVar @@ -33,7 +34,7 @@ from ..sessions import ( thread_exists, ) from ..stream.events import stream_agent_events -from ..stream.state import _INTERNAL_TOOLS, ResearchPhase, StreamState +from ..stream.state import ResearchPhase, StreamState from ._agent_loader import BackgroundAgentLoader, MCPProgressTracker from ._constants import LOGO_GRADIENT, LOGO_LINES, WELCOME_SLOGANS, build_metadata from .channel import ( @@ -220,6 +221,32 @@ def _should_finalize_active_summarization(event_type: str) -> bool: return bool(event_type) and event_type not in _SUMMARY_CONTINUATION_EVENTS +def _response_after_narration(response_text: str, narrated_response_end: int) -> str: + """Return the response suffix that has not already been shown inline.""" + return response_text[max(0, narrated_response_end) :] + + +def _strip_trailing_placeholder_ellipsis(text: str) -> str: + """Remove the standalone streaming placeholder suffix from final TUI text.""" + clean = text.strip() + while clean.endswith("\n...") or clean.rstrip() == "...": + clean = clean.rstrip().removesuffix("...").rstrip() + return clean + + +def _stopped_response_after_narration( + previous_text: str, + narrated_response_end: int, +) -> tuple[str, str, str]: + """Return display-current, display-stopped, and full stopped response text.""" + from ..stream.display import build_stopped_response_text + + display_previous = _response_after_narration(previous_text, narrated_response_end) + display_current, display_stopped = build_stopped_response_text(display_previous) + _, full_stopped = build_stopped_response_text(previous_text) + return display_current, display_stopped, full_stopped + + def run_textual_interactive( *, show_thinking: bool, @@ -240,6 +267,7 @@ def run_textual_interactive( from textual.binding import Binding from textual.containers import Container, Horizontal, VerticalScroll from textual.events import MouseUp + from textual.widget import Widget from textual.widgets import Static from .clipboard import copy_selection_to_clipboard, get_clipboard_text @@ -1220,7 +1248,6 @@ def run_textual_interactive( of mounting the AskUserWidget. """ from ..stream.display import ( - build_stopped_response_text, is_stream_cancel_requested, ) @@ -1242,20 +1269,26 @@ def run_textual_interactive( 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] = {} subagent_widgets: dict[str, SubAgentWidget] = {} + @dataclass + class _ResponseDisplayState: + assistant: AssistantMessage | None = None + narration: AssistantMessage | None = None + assistant_start: int = 0 + narrated_end: int = 0 + + response_display = _ResponseDisplayState() + # Transient indicator widgets (auto-removed on state transitions) - narration_w: Static | None = None # dim italic intermediate text processing_w: Static | None = None # "Analyzing results..." # Tool collapsing (matches CLI MAX_VISIBLE_TOOLS) _MAX_VISIBLE_TOOLS = 4 completed_tool_order: list[str] = [] # tool_ids in completion order collapse_summary_w: Static | None = None - has_used_tools = False _thinking_sent = False _todo_sent = False @@ -1285,7 +1318,7 @@ def run_textual_interactive( metadata = build_metadata(self._workspace_dir, self._current_model) response = "" - async def _remove_w(w: Static | None) -> None: + async def _remove_w(w: Widget | None) -> None: """Safely remove a transient indicator widget.""" if w is not None: try: @@ -1293,26 +1326,78 @@ def run_textual_interactive( except Exception: pass + async def _mount_or_update_narration(text: str) -> None: + """Show the current between-tool text segment inline.""" + content = text.strip() + if not content: + return + if response_display.narration is None: + response_display.narration = AssistantMessage(content) + await container.mount(response_display.narration) + else: + response_display.narration._content = content + await response_display.narration.stop_stream() + response_display.narrated_end = len(state.response_text) + + async def _convert_assistant_to_narration() -> None: + """Turn a provisional answer into inline narration before a tool.""" + if response_display.assistant is None: + return + content = response_display.assistant._content + if content.strip(): + narration = AssistantMessage(content.strip()) + try: + await container.mount( + narration, before=response_display.assistant + ) + except Exception: + await container.mount(narration) + response_display.narrated_end = max( + response_display.narrated_end, + response_display.assistant_start + len(content), + ) + try: + await response_display.assistant.remove() + except Exception: + pass + response_display.assistant = None + response_display.assistant_start = len(state.response_text) + response_display.narration = None + + async def _preserve_active_narration() -> None: + """Finalize inline narration without removing it from the transcript.""" + if response_display.narration is not None: + await response_display.narration.stop_stream() + response_display.narration = None + async def _mark_cancelled_response() -> str: - nonlocal assistant_w previous_text = state.response_text or "" - current, final_text = build_stopped_response_text(previous_text) + current, final_segment, final_text = _stopped_response_after_narration( + previous_text, + response_display.narrated_end, + ) state.response_text = final_text - self._set_status_streaming_text(final_text) + self._set_status_streaming_text(final_segment) - if assistant_w is None: - if final_text: - assistant_w = AssistantMessage(final_text) - await container.mount(assistant_w) + await _preserve_active_narration() + _expand_completed_tools() + + if response_display.assistant is None: + if final_segment: + response_display.assistant_start = len(final_text) - len( + final_segment + ) + response_display.assistant = AssistantMessage(final_segment) + await container.mount(response_display.assistant) else: - if previous_text != current: - assistant_w._content = final_text - await assistant_w.stop_stream() + if response_display.assistant._content != current: + response_display.assistant._content = final_segment + await response_display.assistant.stop_stream() else: - suffix = final_text[len(current) :] + suffix = final_segment[len(current) :] if suffix: - await assistant_w.append_content(suffix) + await response_display.assistant.append_content(suffix) _schedule_scroll() return final_text @@ -1370,6 +1455,13 @@ def run_textual_interactive( collapse_summary_w.update(summary) collapse_summary_w.display = True + def _expand_completed_tools() -> None: + """Show every completed tool once the turn reaches a final state.""" + if collapse_summary_w is not None: + collapse_summary_w.display = False + for tw in tool_widgets.values(): + tw.display = True + def _find_or_rename_sa_widget( resolved_name: str, description: str = "", @@ -1520,39 +1612,38 @@ def run_textual_interactive( _schedule_scroll() elif event_type == "text": + chunk = event.get("content", "") if thinking_w is not None and thinking_w._is_active: thinking_w.finalize() # Clear processing indicator await _remove_w(processing_w) processing_w = None - if has_used_tools and not _is_final_response(state): - # Tools still running — show intermediate narration - await _remove_w(narration_w) - narration_w = None - last_line = ( - state.latest_text.strip().split("\n")[-1].strip() - ) - if last_line: - if len(last_line) > 60: - last_line = last_line[:57] + "\u2026" - narration_w = Static( - Text(f" {last_line}", style="dim italic"), - ) - await container.mount(narration_w) + if not _is_final_response(state): + await _mount_or_update_narration(state.latest_text) + self._set_status_streaming_text(state.latest_text) else: # Stream final response incrementally (both # text-only replies and post-tool responses). - await _remove_w(narration_w) - narration_w = None - if assistant_w is None: - assistant_w = AssistantMessage(state.response_text) - await container.mount(assistant_w) - else: - await assistant_w.append_content( - event.get("content", ""), + await _preserve_active_narration() + if response_display.assistant is None: + response_display.assistant_start = max( + response_display.narrated_end, + len(state.response_text) - len(chunk), ) - self._set_status_streaming_text(state.response_text) + response_display.assistant = AssistantMessage( + state.response_text[ + response_display.assistant_start : + ] + ) + await container.mount(response_display.assistant) + else: + await response_display.assistant.append_content( + chunk + ) + self._set_status_streaming_text( + response_display.assistant._content + ) elif event_type == "tool_call": tool_name = event.get("name", "unknown") @@ -1562,21 +1653,17 @@ def run_textual_interactive( if thinking_w is not None and thinking_w._is_active: thinking_w.finalize() # Clear transient indicators - await _remove_w(narration_w) - narration_w = None + await _preserve_active_narration() await _remove_w(processing_w) processing_w = None - # Remove early AssistantMessage (text arrived before tools) - if assistant_w is not None: - try: - await assistant_w.remove() - except Exception: - pass - assistant_w = None - # Skip internal tools and task (handled by SubAgentWidget) - if tool_name not in _INTERNAL_TOOLS and tool_name != "task": - has_used_tools = True - if tool_id and tool_id in tool_widgets: + existing_tool = bool(tool_id and tool_id in tool_widgets) + if response_display.assistant is not None and ( + tool_name == "task" or not existing_tool + ): + await _convert_assistant_to_narration() + # Task tools are handled by SubAgentWidget. + if tool_name != "task": + if existing_tool: # Re-emitted with updated args — update in place existing = tool_widgets[tool_id] existing._tool_name = tool_name @@ -1839,27 +1926,39 @@ def run_textual_interactive( elif event_type == "done": # Clean up transient indicators - await _remove_w(narration_w) - narration_w = None + await _preserve_active_narration() await _remove_w(processing_w) processing_w = None + _expand_completed_tools() # Mount final response - if assistant_w is None and state.response_text: - # Strip trailing standalone "..." - clean = state.response_text.strip() - while ( - clean.endswith("\n...") or clean.rstrip() == "..." - ): - clean = clean.rstrip().removesuffix("...").rstrip() - assistant_w = AssistantMessage( - clean or state.response_text - ) - await container.mount(assistant_w) - self._schedule_scroll_to_bottom( - container, - delays=(0.15, 0.4, 0.8, 1.5), - immediate=False, - ) + final_response_text = _response_after_narration( + state.response_text, + response_display.narrated_end, + ) + clean = _strip_trailing_placeholder_ellipsis( + final_response_text + ) + if clean: + response_display.assistant_start = len( + state.response_text + ) - len(final_response_text) + if response_display.assistant is None: + response_display.assistant = AssistantMessage(clean) + await container.mount(response_display.assistant) + self._schedule_scroll_to_bottom( + container, + delays=(0.15, 0.4, 0.8, 1.5), + immediate=False, + ) + elif response_display.assistant._content != clean: + response_display.assistant._content = clean + await response_display.assistant.stop_stream() + elif response_display.assistant is not None: + try: + await response_display.assistant.remove() + except Exception: + pass + response_display.assistant = None # Mount token usage stats with elapsed time if state.total_input_tokens or state.total_output_tokens: elapsed = None @@ -1885,7 +1984,9 @@ def run_textual_interactive( response = (state.response_text or "").strip() except asyncio.CancelledError: - # Ctrl+C cancellation — re-raise so _run_turn can handle it + # Ctrl+C cancellation: preserve streamed text before the outer + # turn handler appends its interruption notice. + await _mark_cancelled_response() raise except Exception as exc: error_msg = str(exc) @@ -1911,12 +2012,14 @@ def run_textual_interactive( await loading.cleanup() except Exception: pass - # Clean up transient indicators - for w in (narration_w, processing_w): - await _remove_w(w) + # Clean up transient indicators. Inline narration is transcript + # content, so finalize it without removing the widget. + await _preserve_active_narration() + await _remove_w(processing_w) # Mark any still-running tool widgets as interrupted # (skip if HITL approved — tools will continue next round) if not _hitl_resuming: + _expand_completed_tools() for tw in tool_widgets.values(): if tw._status == "running": try: @@ -1937,8 +2040,8 @@ def run_textual_interactive( except Exception: pass # Finalize assistant message stream - if assistant_w is not None: - await assistant_w.stop_stream() + if response_display.assistant is not None: + await response_display.assistant.stop_stream() # Flush remaining thinking callback if ( on_thinking_cb diff --git a/EvoScientist/middleware/__init__.py b/EvoScientist/middleware/__init__.py index 92ad71e..607340c 100644 --- a/EvoScientist/middleware/__init__.py +++ b/EvoScientist/middleware/__init__.py @@ -20,8 +20,6 @@ from .context_editing import ( from .context_overflow import ContextOverflowMapperMiddleware from .memory import ( EvoMemoryMiddleware, - EvoMemoryState, - ExtractedMemory, create_memory_middleware, ) from .model_fallback import ModelFallbackMiddleware, load_fallback_chain @@ -37,8 +35,6 @@ __all__ = [ "ConfigurableModelMiddleware", "ContextOverflowMapperMiddleware", "EvoMemoryMiddleware", - "EvoMemoryState", - "ExtractedMemory", "ModelFallbackMiddleware", "Question", "ToolErrorHandlerMiddleware", diff --git a/EvoScientist/middleware/memory.py b/EvoScientist/middleware/memory.py index c3d670e..a90b293 100644 --- a/EvoScientist/middleware/memory.py +++ b/EvoScientist/middleware/memory.py @@ -1,823 +1,429 @@ -"""EvoScientist Memory Middleware. +"""Memory middleware for EvoScientist. -Automatically extracts and persists long-term memory (user profile, research -preferences, experiment conclusions) from conversations. - -Two mechanisms: -1. **Injection** (every LLM call): Reads ``/memories/MEMORY.md`` and appends it - to the system prompt so the agent always has context. -2. **Extraction** (threshold-triggered): When the conversation exceeds a - configurable message count, uses an LLM call to pull out structured facts - and merges them into the appropriate MEMORY.md sections. - -## Usage - -```python -from EvoScientist.middleware import EvoMemoryMiddleware - -middleware = EvoMemoryMiddleware( - backend=my_backend, # or backend factory - memory_path="/memories/MEMORY.md", - extraction_model=chat_model, - trigger=("messages", 20), -) -agent = create_deep_agent(middleware=[middleware, ...]) -``` +The middleware owns the markdown files under ``/memories/profile/``: it creates +them when missing, migrates the old ``/memories/MEMORY.md`` file when present, +and injects either profile contents or profile file pointers into model calls. +Agents still read and edit the files through their normal ``/memories/...`` +tools; this middleware only handles setup and prompt context. """ from __future__ import annotations +import asyncio import logging import re +import subprocess from collections.abc import Awaitable, Callable -from contextvars import ContextVar -from datetime import datetime -from typing import TYPE_CHECKING, Annotated, Any, NotRequired, cast +from pathlib import Path from langchain.agents.middleware.types import ( AgentMiddleware, - AgentState, ModelRequest, ModelResponse, - PrivateStateAttr, ) -from langchain.tools import ToolRuntime -from langchain_core.messages import AnyMessage, HumanMessage, filter_messages -from langchain_core.runnables.config import RunnableConfig -from langgraph.runtime import Runtime -from pydantic import BaseModel, Field -if TYPE_CHECKING: - from deepagents.backends.protocol import BACKEND_TYPES, BackendProtocol - from langchain.chat_models import BaseChatModel +from .. import paths as _paths logger = logging.getLogger(__name__) -_CURRENT_MEMORY: ContextVar[str] = ContextVar("evo_memory_current", default="") -_STATE_MEMORY_KEY = "evo_memory_content" +DEFAULT_MAX_INLINE_PROFILE_CHARS = 24_000 +_LEGACY_MEMORY_FILENAME = "MEMORY.md" +_LEGACY_IMPORT_HEADING = "Imported from legacy MEMORY.md" -class EvoMemoryState(AgentState): - """State schema for EvoMemoryMiddleware.""" +PROFILE_INJECTION_TEMPLATE = """ +{profile_content} + - evo_memory_content: NotRequired[Annotated[str, PrivateStateAttr]] + +These profile notes live under `/memories/profile/`. +Every agent can read and update them with normal file tools. + +Use these files for: +- `/memories/profile/SOUL.md`: how this copy should usually behave; voice and boundaries. +- `/memories/profile/USER_PROFILE.md`: facts and preferences about the user. +- `/memories/profile/RESEARCH_TASTE.md`: research interests, standards, methods that fit, and things to avoid. +- `/memories/profile/projects/{project_id}/PROJECT_PROFILE.md`: conventions, commands, and pitfalls for this workspace. + +Read the relevant file before editing it. Add small bullets under existing +headings, skip duplicates, and leave out temporary task state. +""" + +PROFILE_TEMPLATES: dict[str, str] = { + "/profile/SOUL.md": """# EvoScientist soul + +Default behavior for this copy of EvoScientist. + +## Operating principles + +## Voice + +## Lines not to cross +""", + "/profile/USER_PROFILE.md": """# User profile + +Things worth remembering about the person using EvoScientist. + +## Stable facts + +## Preferences + +## Collaboration style + +## Constraints +""", + "/profile/RESEARCH_TASTE.md": """# Research taste + +Research taste to keep in mind: interests, standards, methods that tend to fit, +and things to avoid. + +## Interests + +## Standards + +## Methods that fit + +## Things to avoid +""", + "/profile/projects/{project_id}/PROJECT_PROFILE.md": """# Project profile + +Notes about this workspace: conventions, commands, tests, and traps. + +## Workspace conventions + +## Commands that work + +## Evaluation and testing + +## Known traps +""", +} -# --------------------------------------------------------------------------- -# Structured extraction schemas -# --------------------------------------------------------------------------- +def _short_hash(text: str, *, n: int = 16) -> str: + """Return a deterministic hash fragment for generated profile paths.""" + import hashlib + + return hashlib.sha256(text.encode("utf-8")).hexdigest()[:n] -class UserProfile(BaseModel): - """Extracted user profile information.""" +def _run_git(args: list[str], cwd: Path) -> str | None: + """Run a bounded git query, returning trimmed stdout when it succeeds. - name: str | None = Field(None, description="User's name") - role: str | None = Field(None, description="User's role (e.g. researcher, student)") - institution: str | None = Field( - None, description="User's institution or organization" - ) - language: str | None = Field(None, description="User's preferred language") - - -class ResearchPreferences(BaseModel): - """Extracted research preference information.""" - - primary_domain: str | None = Field(None, description="Primary research domain") - sub_fields: str | None = Field(None, description="Research sub-fields") - preferred_frameworks: str | None = Field( - None, description="Preferred software frameworks" - ) - preferred_models: str | None = Field(None, description="Preferred AI/ML models") - hardware: str | None = Field(None, description="Available hardware (GPUs, etc.)") - constraints: str | None = Field(None, description="Resource or time constraints") - - -class ExperimentConclusion(BaseModel): - """Extracted experiment conclusion (only when a complete experiment was run).""" - - title: str = Field(description="Experiment name") - question: str | None = Field(None, description="Research question") - method: str | None = Field(None, description="Method summary") - key_result: str | None = Field(None, description="Primary metric or outcome") - conclusion: str | None = Field(None, description="One-line conclusion") - artifacts: str | None = Field(None, description="Report path if any") - - -class ExtractedMemory(BaseModel): - """Structured output schema for memory extraction. - - Only fields with genuinely new information should be populated. + Failures are treated as missing metadata so profile setup can fall back to + path-based ids. """ - - user_profile: UserProfile | None = Field( - None, description="New user profile information" - ) - research_preferences: ResearchPreferences | None = Field( - None, description="New research preferences" - ) - experiment_conclusion: ExperimentConclusion | None = Field( - None, description="Completed experiment conclusion" - ) - learned_preferences: list[str] | None = Field( - None, description="New preferences or habits observed" - ) - - -# --------------------------------------------------------------------------- -# Extraction prompt – sent to a (cheap) LLM to pull structured facts -# --------------------------------------------------------------------------- - -EXTRACTION_PROMPT = """\ -You are a memory extraction assistant for a scientific experiment agent called EvoScientist. - -Analyze the following conversation and extract any NEW information that should be -remembered long-term. Only extract facts that are **not already present** in the -current memory shown below. - - -{current_memory} - - - -{conversation} - - -Rules: -- Only populate fields with genuinely new information. -- Leave fields as null if there is nothing new. -- Do NOT repeat information already in . -- For experiment_conclusion, only include if a complete experiment was actually run. -- Be concise. Each value should be a short phrase, not a paragraph. -""" - -# --------------------------------------------------------------------------- -# System-prompt snippet injected every turn -# --------------------------------------------------------------------------- - -MEMORY_INJECTION_TEMPLATE = """Today's date is {date}. - - -{memory_content} - - - -The above contains your long-term memory about the user and past experiments. -Use this to personalize your responses and avoid re-asking known information. - -**When to update memory:** -- User shares their name, role, institution, or language -- User mentions their research domain, preferred frameworks, models, or hardware -- User explicitly asks you to remember something -- An experiment completes with notable conclusions - -**How to update memory:** -- If `/memories/MEMORY.md` does not exist yet, use `write_file` to create it -- If it already exists, use `edit_file` to update specific sections -- Use this markdown structure: - -```markdown -# EvoScientist Memory - -## User Profile -- **Name**: ... -- **Role**: ... -- **Institution**: ... -- **Language**: ... - -## Research Preferences -- **Primary Domain**: ... -- **Sub-fields**: ... -- **Preferred Frameworks**: ... -- **Preferred Models**: ... -- **Hardware**: ... -- **Constraints**: ... - -## Experiment History -### [YYYY-MM-DD] Experiment Title -- **Question**: ... -- **Key Result**: ... -- **Conclusion**: ... - -## Learned Preferences -- ... -``` - -**Priority:** Update memory IMMEDIATELY when the user provides personal or research -information — before composing your main response. -""" - -DEFAULT_MEMORY_TEMPLATE = """# EvoScientist Memory - -## User Profile -- **Name**: (unknown) -- **Role**: (unknown) -- **Institution**: (unknown) -- **Language**: (unknown) - -## Research Preferences -- **Primary Domain**: (unknown) -- **Sub-fields**: (unknown) -- **Preferred Frameworks**: (unknown) -- **Preferred Models**: (unknown) -- **Hardware**: (unknown) -- **Constraints**: (unknown) - -## Experiment History -(No experiments yet) - -## Learned Preferences -- (none yet) -""" - - -def _get_thread_id(runtime: Runtime) -> str: try: - config = cast("RunnableConfig", getattr(runtime, "config", {})) - if isinstance(config, dict): - thread_id = config.get("configurable", {}).get("thread_id") - if thread_id is not None: - return str(thread_id) - except Exception: - logger.debug("Failed to resolve thread_id from runtime config") - return "default" + result = subprocess.run( + ["git", *args], + cwd=str(cwd), + check=False, + capture_output=True, + text=True, + timeout=2, + ) + except (OSError, subprocess.SubprocessError): + return None + if result.returncode != 0: + return None + value = result.stdout.strip() + return value or None -def _ensure_section(content: str, marker: str, body: str) -> str: - if marker in content: - return content - content = content.rstrip() - if content: - content += "\n\n" - return f"{content}{marker}\n{body.rstrip()}\n" +def _resolve_project_id(workspace: str | Path | None = None) -> str: + """Return the stable id used for this workspace's project profile. - -def _ensure_memory_template(existing_md: str) -> str: - if not existing_md.strip(): - return DEFAULT_MEMORY_TEMPLATE - - result = existing_md - if "# EvoScientist Memory" not in result: - result = "# EvoScientist Memory\n\n" + result.lstrip() - - result = _ensure_section( - result, - "## User Profile", - "\n".join( - [ - "- **Name**: (unknown)", - "- **Role**: (unknown)", - "- **Institution**: (unknown)", - "- **Language**: (unknown)", - ], - ), - ) - result = _ensure_section( - result, - "## Research Preferences", - "\n".join( - [ - "- **Primary Domain**: (unknown)", - "- **Sub-fields**: (unknown)", - "- **Preferred Frameworks**: (unknown)", - "- **Preferred Models**: (unknown)", - "- **Hardware**: (unknown)", - "- **Constraints**: (unknown)", - ], - ), - ) - result = _ensure_section(result, "## Experiment History", "(No experiments yet)") - result = _ensure_section(result, "## Learned Preferences", "- (none yet)") - return result - - -def _section_bounds(content: str, marker: str) -> tuple[int | None, int | None]: - idx = content.find(marker) - if idx == -1: - return None, None - start = idx + len(marker) - next_marker = content.find("\n## ", start) - if next_marker == -1: - next_marker = len(content) - return start, next_marker - - -def _normalize_item(value: str) -> str: - return re.sub(r"\s+", " ", value.strip().lower()) - - -# --------------------------------------------------------------------------- -# Helper: merge extracted JSON into MEMORY.md markdown -# --------------------------------------------------------------------------- - - -def _merge_memory(existing_md: str, extracted: dict[str, Any]) -> str: - """Merge extracted fields into the existing MEMORY.md content. - - Performs targeted replacements within the known sections. Unknown - sections or empty extractions are left untouched. + Prefer the git remote when available, then the git root, and finally the + workspace path. """ - if not extracted: - return existing_md - - result = _ensure_memory_template(existing_md) - - # --- User Profile --- - profile = extracted.get("user_profile") - if profile and isinstance(profile, dict): - field_map = { - "name": "Name", - "role": "Role", - "institution": "Institution", - "language": "Language", - } - for key, label in field_map.items(): - value = profile.get(key) - if value and value != "null": - # Replace the line "- **Label**: ..." with new value - pattern = rf"(- \*\*{label}\*\*: ).*" - result = re.sub(pattern, lambda m, v=value: m.group(1) + v, result) - - # --- Research Preferences --- - prefs = extracted.get("research_preferences") - if prefs and isinstance(prefs, dict): - field_map = { - "primary_domain": "Primary Domain", - "sub_fields": "Sub-fields", - "preferred_frameworks": "Preferred Frameworks", - "preferred_models": "Preferred Models", - "hardware": "Hardware", - "constraints": "Constraints", - } - for key, label in field_map.items(): - value = prefs.get(key) - if value and value != "null": - pattern = rf"(- \*\*{label}\*\*: ).*" - result = re.sub(pattern, lambda m, v=value: m.group(1) + v, result) - - # --- Experiment History (append) --- - exp = extracted.get("experiment_conclusion") - should_add_exp = bool(exp and isinstance(exp, dict) and exp.get("title")) - if should_add_exp: - date_str = datetime.now().strftime("%Y-%m-%d") - title = str(exp.get("title", "Untitled")).strip() - entry = f"\n### [{date_str}] {title}\n" - entry += f"- **Question**: {exp.get('question', 'N/A')}\n" - entry += f"- **Method**: {exp.get('method', 'N/A')}\n" - entry += f"- **Key Result**: {exp.get('key_result', 'N/A')}\n" - entry += f"- **Conclusion**: {exp.get('conclusion', 'N/A')}\n" - if exp.get("artifacts"): - entry += f"- **Artifacts**: {exp['artifacts']}\n" - - # Remove placeholder if present - exp_start, exp_end = _section_bounds(result, "## Experiment History") - if exp_start is not None and exp_end is not None: - exp_section = result[exp_start:exp_end] - exp_lines = [ - line - for line in exp_section.splitlines() - if "(No experiments yet)" not in line - ] - result = ( - result[:exp_start] - + "\n" - + "\n".join(exp_lines).strip("\n") - + "\n" - + result[exp_end:] - ) - - # De-duplicate by title if already present - if re.search(rf"### \[[0-9-]+\] {re.escape(title)}\b", result): - should_add_exp = False - - if should_add_exp and exp and isinstance(exp, dict) and exp.get("title"): - # Insert before "## Learned Preferences" - marker = "## Learned Preferences" - if marker in result: - result = result.replace(marker, entry + "\n" + marker) - else: - # Fallback: append at end - result = result.rstrip() + "\n" + entry - - # --- Learned Preferences (append) --- - learned = extracted.get("learned_preferences") - if learned and isinstance(learned, list): - marker = "## Learned Preferences" - start, end = _section_bounds(result, marker) - if start is None or end is None: - result = _ensure_section(result, marker, "- (none yet)") - start, end = _section_bounds(result, marker) - - if start is not None and end is not None: - section = result[start:end] - section_lines = [ - line - for line in section.splitlines() - if line.strip() and line.strip() not in {"- (none yet)", "- (none)"} - ] - existing_items = { - _normalize_item(line[2:]) - for line in section_lines - if line.strip().startswith("- ") - } - new_lines = [] - for item in learned: - if not item: - continue - normalized = _normalize_item(str(item)) - if normalized in existing_items: - continue - existing_items.add(normalized) - new_lines.append(f"- {item}") - - if new_lines: - section_lines.extend(new_lines) - rebuilt = "\n" + "\n".join(section_lines).strip("\n") + "\n" - result = result[:start] + rebuilt + result[end:] - - return result + root = Path(workspace or _paths.WORKSPACE_ROOT).expanduser().resolve() + git_root = _run_git(["rev-parse", "--show-toplevel"], root) + if git_root: + git_root_path = Path(git_root).expanduser().resolve() + remote = _run_git(["remote", "get-url", "origin"], git_root_path) + source = f"git-remote:{remote}" if remote else f"git-root:{git_root_path}" + return f"P-{_short_hash(source)}" + return f"P-{_short_hash(f'path:{root}')}" -# --------------------------------------------------------------------------- -# Middleware -# --------------------------------------------------------------------------- +def _profile_specs(project_id: str) -> list[tuple[str, str]]: + """Return the profile files owned by this middleware and their templates.""" + return [ + (path.format(project_id=project_id), template) + for path, template in PROFILE_TEMPLATES.items() + ] + + +def _agent_path(memory_path: str) -> str: + """Translate a memory-relative path to the virtual path agents see.""" + return f"/memories{memory_path}" + + +def _legacy_sections(content: str) -> tuple[str, list[tuple[str, str]]]: + """Split the old ``MEMORY.md`` format into preface and top-level sections.""" + pattern = re.compile( + r"^## (?P.+?)\n(?P.*?)(?=^## |\Z)", + flags=re.MULTILINE | re.DOTALL, + ) + sections = [ + (match.group("heading").strip(), match.group("body").strip()) + for match in pattern.finditer(content) + ] + first = pattern.search(content) + preface = content[: first.start()].strip() if first else content.strip() + return preface, sections + + +def _is_legacy_placeholder_line(line: str) -> bool: + """Return whether a legacy line is only default-template filler.""" + stripped = line.strip() + if stripped in {"", "- (none yet)", "- (none)", "(No experiments yet)", "(none)"}: + return True + return bool(re.fullmatch(r"- \*\*[^*]+\*\*:\s*\(unknown\)", stripped)) + + +def _clean_legacy_body(body: str) -> str: + """Drop old template placeholders while keeping real legacy notes.""" + lines = [ + line.rstrip() + for line in body.strip().splitlines() + if not _is_legacy_placeholder_line(line) + ] + return "\n".join(lines).strip() + + +def _clean_legacy_preface(preface: str) -> str: + """Remove the old root heading from pre-section legacy text.""" + lines = [ + line.rstrip() + for line in preface.strip().splitlines() + if line.strip() != "# EvoScientist Memory" + ] + return "\n".join(lines).strip() + + +def _append_imported_section(content: str, body: str) -> str: + """Append migrated legacy text under a clear, inspectable heading.""" + return content.rstrip() + f"\n\n## {_LEGACY_IMPORT_HEADING}\n\n{body.strip()}\n" class EvoMemoryMiddleware(AgentMiddleware): - """Middleware that injects and auto-extracts long-term memory. + """Middleware that maintains the profile memory files used by EvoScientist. - Args: - backend: Backend instance or factory for reading/writing memory files. - memory_path: Virtual path to MEMORY.md (default ``/memories/MEMORY.md``). - extraction_model: Chat model used for extraction (can be a cheap/fast - model like ``claude-haiku``). If ``None``, automatic extraction is - disabled and only prompt injection + manual ``edit_file`` works. - trigger: When to run automatic extraction. Supports - ``("messages", N)`` to trigger every *N* human messages. - Defaults to ``("messages", 20)``. + The middleware bootstraps missing files, migrates legacy memory, and adds + profile context to model requests. """ - state_schema = EvoMemoryState - def __init__( self, *, - backend: BACKEND_TYPES, - memory_path: str = "/memories/MEMORY.md", - extraction_model: BaseChatModel | None = None, - trigger: tuple[str, int] = ("messages", 20), + memory_dir: str | Path, + workspace_dir: str | Path | None = None, + max_inline_profile_chars: int = DEFAULT_MAX_INLINE_PROFILE_CHARS, ) -> None: - self._backend = backend - self._memory_path = memory_path - self._extraction_model = extraction_model - self._trigger = trigger - self._last_extraction_at: dict[str, int] = {} # message count per thread - - # -- backend resolution -------------------------------------------------- - - def _get_backend( - self, - state: AgentState[Any], - runtime: Runtime, - ) -> BackendProtocol: - if callable(self._backend): - config = cast("RunnableConfig", getattr(runtime, "config", {})) - tool_runtime = ToolRuntime( - state=state, - context=runtime.context, - stream_writer=runtime.stream_writer, - store=runtime.store, - config=config, - tool_call_id=None, - ) - return self._backend(tool_runtime) - return self._backend - - # -- agent-level preload ------------------------------------------------- - - def before_agent( - self, - state: AgentState[Any], - runtime: Runtime, - config: RunnableConfig, - ) -> dict[str, Any] | None: - if state.get(_STATE_MEMORY_KEY) is not None: - return None - backend = self._get_backend(state, runtime) - memory = self._read_memory(backend) - _CURRENT_MEMORY.set(memory) - return {_STATE_MEMORY_KEY: memory} - - async def abefore_agent( - self, - state: AgentState[Any], - runtime: Runtime, - config: RunnableConfig, - ) -> dict[str, Any] | None: - if state.get(_STATE_MEMORY_KEY) is not None: - return None - backend = self._get_backend(state, runtime) - memory = await self._aread_memory(backend) - _CURRENT_MEMORY.set(memory) - return {_STATE_MEMORY_KEY: memory} - - # -- read / write helpers ------------------------------------------------ - - def _read_memory(self, backend: BackendProtocol) -> str: - """Read MEMORY.md content (raw bytes → str).""" - try: - responses = backend.download_files([self._memory_path]) - if ( - responses - and responses[0].content is not None - and responses[0].error is None - ): - return responses[0].content.decode("utf-8") - except Exception as e: - logger.debug("Failed to read memory at %s: %s", self._memory_path, e) - return "" - - async def _aread_memory(self, backend: BackendProtocol) -> str: - try: - responses = await backend.adownload_files([self._memory_path]) - if ( - responses - and responses[0].content is not None - and responses[0].error is None - ): - return responses[0].content.decode("utf-8") - except Exception as e: - logger.debug("Failed to read memory at %s: %s", self._memory_path, e) - return "" - - def _write_memory( - self, backend: BackendProtocol, old_content: str, new_content: str - ) -> None: - """Write updated MEMORY.md (edit if exists, write if new).""" - try: - if old_content: - result = backend.edit(self._memory_path, old_content, new_content) - else: - result = backend.write(self._memory_path, new_content) - if result and result.error: - logger.warning("Failed to write memory: %s", result.error) - except Exception as e: - logger.warning("Exception writing memory: %s", e) - - async def _awrite_memory( - self, backend: BackendProtocol, old_content: str, new_content: str - ) -> None: - try: - if old_content: - result = await backend.aedit( - self._memory_path, old_content, new_content - ) - else: - result = await backend.awrite(self._memory_path, new_content) - if result and result.error: - logger.warning("Failed to write memory: %s", result.error) - except Exception as e: - logger.warning("Exception writing memory: %s", e) - - # -- threshold check ----------------------------------------------------- - - def _should_extract(self, thread_id: str, messages: list[AnyMessage]) -> bool: - """Check if we should run automatic extraction.""" - if self._extraction_model is None: - return False - - trigger_type, trigger_value = self._trigger - if trigger_type == "messages": - human_count = sum(1 for m in messages if isinstance(m, HumanMessage)) - last = self._last_extraction_at.get(thread_id, 0) - return (human_count - last) >= trigger_value - return False - - # -- extraction ---------------------------------------------------------- - - @staticmethod - def _build_extraction_prompt(memory: str, messages: list[AnyMessage]) -> str: - """Build the extraction prompt from recent human/AI messages.""" - recent = filter_messages(messages[-30:], include_types=["human", "ai"]) - conv_parts = [] - for msg in recent: - role = "user" if isinstance(msg, HumanMessage) else "assistant" - content = msg.content if isinstance(msg.content, str) else str(msg.content) - conv_parts.append(f"[{role}]: {content}") - return EXTRACTION_PROMPT.format( - current_memory=memory, - conversation="\n".join(conv_parts), + self._memory_dir = Path(memory_dir).expanduser() + workspace = Path(workspace_dir or _paths.WORKSPACE_ROOT).expanduser() + self._project_id = _resolve_project_id(workspace) + self._profile_specs = _profile_specs(self._project_id) + pointer_lines = ["Profile files are available at:"] + pointer_lines.extend( + f"- {_agent_path(path)}" for path, _ in self._profile_specs ) + self._profile_pointer_context = "\n".join(pointer_lines) + self._max_inline_profile_chars = max_inline_profile_chars - @staticmethod - def _structured_output_kwargs(model: BaseChatModel) -> dict[str, Any]: - """Return extra kwargs for with_structured_output based on provider. + def _file_path(self, memory_path: str) -> Path: + """Resolve a memory-relative path against the memory directory.""" + return self._memory_dir / memory_path.lstrip("/") - OpenAI's Structured Outputs (default since langchain-openai 0.3) - requires ``additionalProperties: false`` and all-required fields. - The ExtractedMemory schema uses Optional unions which violate these - rules. Fall back to function_calling for OpenAI models. - """ - model_module = type(model).__module__ or "" - if model_module.startswith("langchain_openai"): - return {"method": "function_calling"} - return {} - - @staticmethod - def _disable_thinking(model: BaseChatModel) -> BaseChatModel: - """Return a copy of the model with thinking/reasoning disabled. - - Delegates to the shared :func:`~.utils.disable_thinking` utility. - Kept as a static method for backward compatibility. - """ - from .utils import disable_thinking - - return disable_thinking(model) - - def _extract( - self, model: BaseChatModel, memory: str, messages: list[AnyMessage] - ) -> dict[str, Any]: - """Run LLM extraction on recent messages using structured output.""" - prompt = self._build_extraction_prompt(memory, messages) + def _read_text(self, path: Path) -> str | None: + """Read UTF-8 text, returning None only when the file is absent.""" try: - plain_model = self._disable_thinking(model) - so_kwargs = self._structured_output_kwargs(plain_model) - structured_model = plain_model.with_structured_output( - ExtractedMemory, **so_kwargs - ) - result = structured_model.invoke(prompt) - return result.model_dump(exclude_none=True) - except Exception as e: - logger.warning("Memory extraction failed: %s", e) - return {} + return path.read_text(encoding="utf-8") + except FileNotFoundError: + return None + except (OSError, UnicodeDecodeError) as e: + logger.warning("Failed to read profile memory %s: %s", path, e) + raise - async def _aextract( - self, model: BaseChatModel, memory: str, messages: list[AnyMessage] - ) -> dict[str, Any]: - """Async: Run LLM extraction on recent messages using structured output.""" - prompt = self._build_extraction_prompt(memory, messages) + def _write_text(self, path: Path, content: str) -> bool: + """Write UTF-8 text, creating parent directories as needed.""" try: - plain_model = self._disable_thinking(model) - so_kwargs = self._structured_output_kwargs(plain_model) - structured_model = plain_model.with_structured_output( - ExtractedMemory, **so_kwargs - ) - result = await structured_model.ainvoke(prompt) - return result.model_dump(exclude_none=True) - except Exception as e: - logger.warning("Memory extraction failed: %s", e) - return {} + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + except OSError as e: + logger.warning("Failed to write profile memory %s: %s", path, e) + return False + return True - # -- middleware hooks ----------------------------------------------------- + def _delete_legacy_memory(self, legacy_path: Path) -> bool: + """Remove the old memory file after it has no content left to preserve.""" + try: + legacy_path.unlink() + except FileNotFoundError: + pass + except OSError as e: + logger.warning("Failed to delete legacy memory %s: %s", legacy_path, e) + return False + return True - def modify_request(self, request: ModelRequest) -> ModelRequest: - """Inject memory content and instructions into the system message. + def _ensure_profile_files(self) -> list[tuple[str, str]]: + """Create the expected profile files if needed and return their contents.""" + records = [] + for memory_path, template in self._profile_specs: + path = self._file_path(memory_path) + content = self._read_text(path) + if content is None: + if not self._write_text(path, template): + raise OSError(f"Failed to bootstrap profile file: {path}") + content = template + records.append((memory_path, content)) + return records - Always injects ```` so the agent knows it can - save memories, even when MEMORY.md does not exist yet. + def _migrate_legacy_memory(self) -> bool: + """Import recognized sections from legacy ``MEMORY.md`` into profiles. + + The legacy file is removed only after real content is copied or the file + is found to contain only old template placeholders. """ - state = request.state or {} - memory_content = state.get(_STATE_MEMORY_KEY, "") - if not memory_content: - memory_content = _CURRENT_MEMORY.get() - if not memory_content and request.runtime is not None: - try: - backend = self._get_backend(state, request.runtime) - memory_content = self._read_memory(backend) - _CURRENT_MEMORY.set(memory_content) - except Exception as e: - logger.debug("Failed to load memory during modify_request: %s", e) - # Use placeholder when memory file doesn't exist yet - if not memory_content: - memory_content = "(No memory saved yet. Create `/memories/MEMORY.md` when you learn important information.)" + legacy_path = self._memory_dir / _LEGACY_MEMORY_FILENAME + legacy = self._read_text(legacy_path) + if legacy is None: + return True + if not legacy.strip(): + return self._delete_legacy_memory(legacy_path) + user_profile_path = "/profile/USER_PROFILE.md" + research_taste_path = "/profile/RESEARCH_TASTE.md" + imports: dict[str, list[str]] = { + user_profile_path: [], + research_taste_path: [], + } + recognized_paths = { + "User Profile": user_profile_path, + "Research Preferences": research_taste_path, + "Experiment History": user_profile_path, + "Learned Preferences": user_profile_path, + } + + preface, legacy_sections = _legacy_sections(legacy) + preface_body = _clean_legacy_preface(preface) + if preface_body: + imports[user_profile_path].append(f"### Notes\n{preface_body}") + for heading, body in legacy_sections: + cleaned = _clean_legacy_body(body) + if not cleaned: + continue + target_path = recognized_paths.get(heading, user_profile_path) + imports.setdefault(target_path, []).append(f"### {heading}\n{cleaned}") + + imported_any = False + for memory_path, bodies in imports.items(): + if not bodies: + continue + path = self._file_path(memory_path) + content = self._read_text(path) + if content is None: + logger.warning( + "Skipping legacy memory migration for missing profile %s", path + ) + return False + body = "\n\n".join(bodies) + if not self._write_text(path, _append_imported_section(content, body)): + return False + imported_any = True + + if not imported_any: + logger.debug("Legacy MEMORY.md contained no real content to migrate") + + return self._delete_legacy_memory(legacy_path) + + def _read_profile_records(self) -> list[tuple[str, str]]: + """Load all profile files after bootstrapping and legacy migration.""" + records = self._ensure_profile_files() + if self._migrate_legacy_memory(): + records = [ + (memory_path, self._read_text(self._file_path(memory_path)) or "") + for memory_path, _ in records + ] + return records + + def _profile_context_from_records(self, records: list[tuple[str, str]]) -> str: + """Inline profile contents unless they exceed the prompt budget.""" + full = "\n\n".join( + f"File: {_agent_path(path)}\n\n{content.strip()}" + for path, content in records + if content.strip() + ).strip() + if len(full) <= self._max_inline_profile_chars: + return full + return self._profile_pointer_context + + def _read_profile_memory(self) -> str: + """Return profile context, falling back to file pointers on setup errors.""" + try: + records = self._read_profile_records() + return self._profile_context_from_records(records) + except Exception as e: + logger.debug("Failed to read profile memory: %s", e) + return self._profile_pointer_context + + def _inject_profile_context( + self, request: ModelRequest, profile_content: str + ) -> ModelRequest: + """Append profile context and editing guidance to the system prompt.""" from deepagents.middleware._utils import append_to_system_message - date_str = datetime.now().strftime("%Y-%m-%d") - injection = MEMORY_INJECTION_TEMPLATE.format( - memory_content=memory_content, date=date_str + injection = PROFILE_INJECTION_TEMPLATE.format( + profile_content=profile_content, + project_id=self._project_id, ) new_system = append_to_system_message(request.system_message, injection) return request.override(system_message=new_system) + def modify_request(self, request: ModelRequest) -> ModelRequest: + """Apply profile memory injection for synchronous model calls.""" + profile_content = self._read_profile_memory() + if not profile_content: + profile_content = self._profile_pointer_context + return self._inject_profile_context(request, profile_content) + + async def amodify_request(self, request: ModelRequest) -> ModelRequest: + """Async profile injection; file reads run off the event loop.""" + profile_content = await asyncio.to_thread(self._read_profile_memory) + if not profile_content: + profile_content = self._profile_pointer_context + return self._inject_profile_context(request, profile_content) + def wrap_model_call( self, request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse], ) -> ModelResponse: - """Inject memory into system prompt before every LLM call.""" - modified = self.modify_request(request) - return handler(modified) + """Middleware hook for injecting context before the sync model handler.""" + return handler(self.modify_request(request)) async def awrap_model_call( self, request: ModelRequest, handler: Callable[[ModelRequest], Awaitable[ModelResponse]], ) -> ModelResponse: - modified = self.modify_request(request) - return await handler(modified) - - def before_model( - self, - state: AgentState[Any], - runtime: Runtime, - ) -> dict[str, Any] | None: - """Read memory and optionally run extraction before each LLM call.""" - backend = self._get_backend(state, runtime) - messages = state["messages"] - thread_id = _get_thread_id(runtime) - - # Always read memory for injection - memory = self._read_memory(backend) - _CURRENT_MEMORY.set(memory) - state_update: dict[str, Any] | None = None - if state.get(_STATE_MEMORY_KEY) != memory: - state_update = {_STATE_MEMORY_KEY: memory} - - # Check extraction threshold - if self._should_extract(thread_id, messages): - human_count = sum(1 for m in messages if isinstance(m, HumanMessage)) - extracted = self._extract(self._extraction_model, memory, messages) - if extracted: - new_memory = _merge_memory(memory, extracted) - if new_memory != memory: - self._write_memory(backend, memory, new_memory) - _CURRENT_MEMORY.set(new_memory) - logger.info("Auto-extracted and updated memory") - state_update = {_STATE_MEMORY_KEY: new_memory} - self._last_extraction_at[thread_id] = human_count - - return state_update - - async def abefore_model( - self, - state: AgentState[Any], - runtime: Runtime, - ) -> dict[str, Any] | None: - """Async: Read memory and optionally run extraction.""" - backend = self._get_backend(state, runtime) - messages = state["messages"] - thread_id = _get_thread_id(runtime) - - memory = await self._aread_memory(backend) - _CURRENT_MEMORY.set(memory) - state_update: dict[str, Any] | None = None - if state.get(_STATE_MEMORY_KEY) != memory: - state_update = {_STATE_MEMORY_KEY: memory} - - if self._should_extract(thread_id, messages): - human_count = sum(1 for m in messages if isinstance(m, HumanMessage)) - extracted = await self._aextract(self._extraction_model, memory, messages) - if extracted: - new_memory = _merge_memory(memory, extracted) - if new_memory != memory: - await self._awrite_memory(backend, memory, new_memory) - _CURRENT_MEMORY.set(new_memory) - logger.info("Auto-extracted and updated memory") - state_update = {_STATE_MEMORY_KEY: new_memory} - self._last_extraction_at[thread_id] = human_count - - return state_update - - -# --------------------------------------------------------------------------- -# Factory -# --------------------------------------------------------------------------- + """Middleware hook for injecting context before the async model handler.""" + return await handler(await self.amodify_request(request)) def create_memory_middleware( memory_dir: str | None = None, - extraction_model: BaseChatModel | None = None, - trigger: tuple[str, int] = ("messages", 20), + workspace_dir: str | Path | None = None, + max_inline_profile_chars: int = DEFAULT_MAX_INLINE_PROFILE_CHARS, ) -> EvoMemoryMiddleware: - """Create an EvoMemoryMiddleware for long-term memory. - - Uses a FilesystemBackend rooted at ``memory_dir`` so that memory - persists across threads and sessions. - - Args: - memory_dir: Path to the shared memory directory (not per-session). - Defaults to ``paths.MEMORY_DIR``. - extraction_model: Chat model for auto-extraction (optional; if None, - only prompt-guided manual memory updates via edit_file will work). - trigger: When to auto-extract. Default: every 20 human messages. - - Returns: - Configured EvoMemoryMiddleware instance. - """ - from deepagents.backends import FilesystemBackend - - from ..paths import MEMORIES_DIR as _DEFAULT_MEMORY_DIR + """Build profile-memory middleware, defaulting to the shared memories directory.""" if memory_dir is None: - memory_dir = str(_DEFAULT_MEMORY_DIR) + memory_dir = str(_paths.MEMORIES_DIR) - memory_backend = FilesystemBackend( - root_dir=memory_dir, - virtual_mode=True, - ) return EvoMemoryMiddleware( - backend=memory_backend, - memory_path="/MEMORY.md", - extraction_model=extraction_model, - trigger=trigger, + memory_dir=memory_dir, + workspace_dir=workspace_dir, + max_inline_profile_chars=max_inline_profile_chars, ) diff --git a/EvoScientist/stream/display.py b/EvoScientist/stream/display.py index 66d20b2..2882bcd 100644 --- a/EvoScientist/stream/display.py +++ b/EvoScientist/stream/display.py @@ -27,7 +27,6 @@ from .diff_format import build_edit_diff from .events import stream_agent_events from .formatter import ToolResultFormatter from .state import ( - _INTERNAL_TOOLS, StreamState, SubAgentState, _build_todo_stats, @@ -65,6 +64,38 @@ def _fix_markdown_heading_spacing(text: str) -> str: return _HEADING_FIX_RE.sub(r"\1 ", text) +def _split_response_for_display( + response_text: str, + narrated_response_end: int, +) -> tuple[str, str]: + """Split cumulative response text into narrated prefix and answer suffix.""" + boundary = max(0, min(len(response_text), narrated_response_end)) + return response_text[:boundary], response_text[boundary:] + + +def _clean_response_text(text: str) -> str: + """Trim a streamed response copy for display.""" + clean = text.strip() + while clean.endswith("\n...") or clean.rstrip() == "...": + clean = clean.rstrip().removesuffix("...").rstrip() + return clean + + +def _response_markdown_for_display( + text: str, + *, + response_markdown: Any = None, + full_response_text: str = "", +) -> Any | None: + """Build Markdown for the answer text, reusing the full-response cache if valid.""" + clean = _clean_response_text(text) + if not clean: + return None + if response_markdown is not None and text == full_response_text: + return response_markdown + return Markdown(_fix_markdown_heading_spacing(clean)) + + formatter = ToolResultFormatter() @@ -472,6 +503,8 @@ def create_streaming_display( final_show_thinking: bool = False, final_thinking_max_length: int = DisplayLimits.THINKING_FINAL, response_markdown: Any = None, + narrated_response_end: int = 0, + narration_segments: list[tuple[int, str]] | None = None, total_input_tokens: int = 0, total_output_tokens: int = 0, summarization_text: str = "", @@ -568,11 +601,72 @@ def create_streaming_display( ) ) + # Response text handling: keep the final answer behind pending tool calls. + _n_tools = len(tool_calls) + _n_done = min(len(tool_results), _n_tools) + has_pending_tools = _n_tools > _n_done + any_active_subagent = any(sa.is_active for sa in subagents) + is_processing_blocking = is_processing + all_done = ( + not has_pending_tools and not any_active_subagent and not is_processing_blocking + ) + _, answer_text = _split_response_for_display( + response_text, + narrated_response_end, + ) + narration_by_tool: dict[int, list[str]] = {} + for tool_index, text in narration_segments or []: + if text.strip(): + narration_by_tool.setdefault(tool_index, []).append(text) + + def _append_narration_before_tool(tool_index: int) -> None: + for text in narration_by_tool.get(tool_index, []): + narration_markdown = _response_markdown_for_display(text) + if narration_markdown is not None: + elements.append(Text("")) # blank separator + elements.append(narration_markdown) + + def _find_task_subagent(tc: dict, shown_sa_names: set[str]) -> SubAgentState | None: + sa_name = tc.get("args", {}).get("subagent_type", "") + task_desc = tc.get("args", {}).get("description", "") + for sa in subagents: + if sa.name in shown_sa_names: + continue + if sa.name == sa_name or ( + task_desc and task_desc in (sa.description or "") + ): + return sa + + candidates = [ + sa + for sa in subagents + if sa.name not in shown_sa_names and (sa.tool_calls or sa.is_active) + ] + if len(candidates) == 1: + return candidates[0] + return None + + def _append_task_entry( + tool_index: int, + tc: dict, + tr: dict | None, + *, + shown_sa_names: set[str], + compact: bool, + ) -> None: + _append_narration_before_tool(tool_index) + elements.append(_render_tool_call_line(tc, tr)) + matched_sa = _find_task_subagent(tc, shown_sa_names) + if matched_sa is not None: + shown_sa_names.add(matched_sa.name) + elements.extend(_render_subagent_section(matched_sa, compact=compact)) + # 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) MAX_VISIBLE_TOOLS = 4 MAX_VISIBLE_RUNNING = 3 + shown_sa_names: set[str] = set() if tool_calls: # Split into categories @@ -585,24 +679,30 @@ def create_streaming_display( tr = tool_results[i] if has_result else None is_task = tc.get("name") == "task" - # Skip internal middleware tools - if tc.get("name") in _INTERNAL_TOOLS: - continue - if is_task: - # Skip task calls with empty args (still streaming) - if tc.get("args"): - task_tools.append((tc, tr)) + task_tools.append((i, tc, tr)) elif has_result: - completed_regular.append((tc, tr)) + completed_regular.append((i, tc, tr)) else: - running_regular.append((tc, None)) + running_regular.append((i, tc, None)) if is_final: # Final frame: show ALL tools expanded, no spinners, no collapsing - shown_sa_names: set[str] = set() + for tool_index, tc, tr in sorted( + completed_regular + running_regular + task_tools, + key=lambda item: item[0], + ): + if tc.get("name") == "task": + _append_task_entry( + tool_index, + tc, + tr, + shown_sa_names=shown_sa_names, + compact=True, + ) + continue - for tc, tr in completed_regular: + _append_narration_before_tool(tool_index) elements.append(_render_tool_call_line(tc, tr)) content = tr.get("content", "") if tr else "" if tr and (not is_success(content) or tc.get("name") == "edit_file"): @@ -614,22 +714,6 @@ def create_streaming_display( ) elements.extend(result_elements) - # Task tools with compact sub-agent summaries - for tc, tr in task_tools: - elements.append(_render_tool_call_line(tc, tr)) - sa_name = tc.get("args", {}).get("subagent_type", "") - task_desc = tc.get("args", {}).get("description", "") - matched_sa = None - for sa in subagents: - if sa.name == sa_name or ( - task_desc and task_desc in (sa.description or "") - ): - matched_sa = sa - break - if matched_sa: - shown_sa_names.add(matched_sa.name) - elements.extend(_render_subagent_section(matched_sa, compact=True)) - # Render any sub-agents not already shown via task tool calls for sa in subagents: if sa.name not in shown_sa_names and (sa.tool_calls or sa.is_active): @@ -647,7 +731,9 @@ def create_streaming_display( visible = completed_regular[-slots:] if slots else [] if hidden: - ok = sum(1 for _, tr in hidden if is_success(tr.get("content", ""))) + for tool_index, _, _ in hidden: + _append_narration_before_tool(tool_index) + ok = sum(1 for _, _, tr in hidden if is_success(tr.get("content", ""))) fail = len(hidden) - ok summary = Text() summary.append(f"\u2713 {ok} completed", style="dim green") @@ -655,10 +741,42 @@ def create_streaming_display( summary.append(f" | {fail} failed", style="dim red") elements.append(summary) - for tc, tr in visible: + # --- Running regular tools (limit visible) --- + hidden_running = len(running_regular) - MAX_VISIBLE_RUNNING + if hidden_running > 0: + hidden_running_tools = running_regular[:-MAX_VISIBLE_RUNNING] + for tool_index, _, _ in hidden_running_tools: + _append_narration_before_tool(tool_index) + summary = Text() + summary.append( + f"\u25cf {hidden_running} more running...", style="dim yellow" + ) + elements.append(summary) + running_regular = running_regular[-MAX_VISIBLE_RUNNING:] + + for tool_index, tc, tr in sorted( + visible + running_regular + task_tools, + key=lambda item: item[0], + ): + if tc.get("name") == "task": + matched_sa = _find_task_subagent(tc, shown_sa_names) + _append_task_entry( + tool_index, + tc, + tr, + shown_sa_names=shown_sa_names, + compact=not matched_sa.is_active if matched_sa else True, + ) + continue + + _append_narration_before_tool(tool_index) elements.append(_render_tool_call_line(tc, tr)) - content = tr.get("content", "") if tr else "" - if tr and (not is_success(content) or tc.get("name") == "edit_file"): + if tr is None: + elements.append(Spinner("dots", text=" Running...", style="yellow")) + continue + + content = tr.get("content", "") + if not is_success(content) or tc.get("name") == "edit_file": result_elements = format_tool_result_compact( tr["name"], content, @@ -667,36 +785,7 @@ def create_streaming_display( ) elements.extend(result_elements) - # --- Running regular tools (limit visible) --- - hidden_running = len(running_regular) - MAX_VISIBLE_RUNNING - if hidden_running > 0: - summary = Text() - summary.append( - f"\u25cf {hidden_running} more running...", style="dim yellow" - ) - elements.append(summary) - running_regular = running_regular[-MAX_VISIBLE_RUNNING:] - - for tc, tr in running_regular: - elements.append(_render_tool_call_line(tc, tr)) - elements.append(Spinner("dots", text=" Running...", style="yellow")) - - # Task tool calls are rendered as part of sub-agent sections below - - # Response text handling — exclude internal tools (e.g. ExtractedMemory) - # from the "done" calculation so they don't block final Markdown rendering. - _n_visible = 0 - _n_visible_done = 0 - for i, tc in enumerate(tool_calls): - if tc.get("name") in _INTERNAL_TOOLS: - continue - _n_visible += 1 - if i < len(tool_results): - _n_visible_done += 1 - has_pending_tools = _n_visible > _n_visible_done - any_active_subagent = any(sa.is_active for sa in subagents) - has_used_tools = _n_visible > 0 - all_done = not has_pending_tools and not any_active_subagent and not is_processing + # Remaining sub-agent sections are rendered below. if is_final: # Final frame: render todo panel + response (tools/subagents handled above). @@ -707,17 +796,14 @@ def create_streaming_display( elements.append(Text("")) # blank separator elements.append(_render_todo_panel(todo_items)) - # Include response in final frame so it stays visible after Live exits - if response_text: - clean_response = response_text.strip() - while clean_response.endswith("\n...") or clean_response.rstrip() == "...": - clean_response = clean_response.rstrip().removesuffix("...").rstrip() - if clean_response: - elements.append(Text("")) # blank separator - elements.append( - response_markdown - or Markdown(_fix_markdown_heading_spacing(clean_response)) - ) + answer_markdown = _response_markdown_for_display( + answer_text, + response_markdown=response_markdown, + full_response_text=response_text, + ) + if answer_markdown is not None: + elements.append(Text("")) # blank separator + elements.append(answer_markdown) # Token usage stats (right-aligned) if total_input_tokens or total_output_tokens: @@ -731,16 +817,6 @@ def create_streaming_display( stats.append("]", style="dim italic") elements.append(stats) else: - # Intermediate narration (tools still running) -- dim italic above Task List - if latest_text and has_used_tools and not all_done: - preview = latest_text.strip() - if preview: - last_line = preview.split("\n")[-1].strip() - if last_line: - if len(last_line) > 60: - last_line = last_line[:57] + "\u2026" - elements.append(Text(f" {last_line}", style="dim italic")) - # Task List panel (persistent, updates on write_todos / read_todos) todo_items = todo_items or [] if todo_items: @@ -750,16 +826,11 @@ def create_streaming_display( # Sub-agent activity sections # Active: full bordered view; Completed: compact 1-line summary for sa in subagents: - if sa.tool_calls or sa.is_active: + if sa.name not in shown_sa_names and (sa.tool_calls or sa.is_active): elements.extend(_render_subagent_section(sa, compact=not sa.is_active)) # Processing state after tool execution - if ( - is_processing - and not is_thinking - and not is_responding - and not response_text - ): + if is_processing and not is_thinking and not is_responding: # Check if any sub-agent is active any_active = any(sa.is_active for sa in subagents) if not any_active: @@ -769,11 +840,14 @@ def create_streaming_display( # Stream response in real-time as tokens arrive (all tools done) if response_text and all_done: - elements.append(Text("")) # blank separator - elements.append( - response_markdown - or Markdown(_fix_markdown_heading_spacing(response_text)) + answer_markdown = _response_markdown_for_display( + answer_text, + response_markdown=response_markdown, + full_response_text=response_text, ) + if answer_markdown is not None: + elements.append(Text("")) # blank separator + elements.append(answer_markdown) if not elements: elements.append(Spinner("dots", text=" Processing...", style="cyan")) @@ -849,10 +923,6 @@ def display_final_results( tool_name = tc.get("name", "") is_task = tool_name.lower() == "task" - # Skip internal middleware tools - if tool_name in _INTERNAL_TOOLS: - continue - # Task tools: show delegation line + compact sub-agent summary if is_task: console.print(_render_tool_call_line(tc, tr)) diff --git a/EvoScientist/stream/state.py b/EvoScientist/stream/state.py index 46df60f..8258661 100644 --- a/EvoScientist/stream/state.py +++ b/EvoScientist/stream/state.py @@ -9,10 +9,6 @@ import ast import json from enum import StrEnum -# Tool names that are internal middleware artifacts (not user-visible actions). -# These should be excluded from display rendering and "all_done" calculations. -_INTERNAL_TOOLS = {"ExtractedMemory"} - class ResearchPhase(StrEnum): """Research phase constants used by the TUI status bar.""" @@ -122,6 +118,8 @@ class StreamState: self.todo_items: list[dict] = [] # Latest text segment (reset on each tool_call) self.latest_text = "" + self.narrated_response_end = 0 + self.narration_segments = [] # Token usage tracking self.total_input_tokens = 0 self.total_output_tokens = 0 @@ -139,7 +137,7 @@ class StreamState: def get_response_markdown(self): """Return cached Markdown object, only re-parsing when text changes.""" - from rich.markdown import Markdown # type: ignore[import-untyped] + from rich.markdown import Markdown from .display import _fix_markdown_heading_spacing @@ -221,7 +219,6 @@ class StreamState: self.is_thinking = False self.is_responding = False self.is_processing = False - self.latest_text = "" # Reset -- next text segment is a new message tool_id = event.get("id", "") tool_name = event.get("name", "unknown") @@ -240,10 +237,22 @@ class StreamState: updated = True break if not updated: + if self.latest_text.strip(): + self.narration_segments.append( + (len(self.tool_calls), self.latest_text) + ) + self.narrated_response_end = len(self.response_text) self.tool_calls.append(tc_data) else: + if self.latest_text.strip(): + self.narration_segments.append( + (len(self.tool_calls), self.latest_text) + ) + self.narrated_response_end = len(self.response_text) self.tool_calls.append(tc_data) + self.latest_text = "" # Reset -- next text segment is a new message + # Capture todo items from write_todos args (most reliable source) if tool_name == "write_todos": todos = tool_args.get("todos", []) @@ -252,8 +261,7 @@ class StreamState: elif event_type == "tool_result": result_name = event.get("name", "unknown") - if result_name not in _INTERNAL_TOOLS: - self.is_processing = True + self.is_processing = True result_content = event.get("content", "") self.tool_results.append( { @@ -352,16 +360,9 @@ class StreamState: return event_type def visible_tool_counts(self) -> tuple[int, int]: - """Return (completed, total) counts for visible (non-internal) tools.""" - n_visible = 0 - n_done = 0 - for i, tc in enumerate(self.tool_calls): - if tc.get("name") in _INTERNAL_TOOLS: - continue - n_visible += 1 - if i < len(self.tool_results): - n_done += 1 - return n_done, n_visible + """Return (completed, total) counts for tool calls.""" + n_total = len(self.tool_calls) + return min(len(self.tool_results), n_total), n_total def has_pending_work(self) -> bool: """Return True if tools or sub-agents are still running.""" @@ -396,6 +397,8 @@ class StreamState: "is_summarizing": self.is_summarizing, "response_text": self.response_text, "latest_text": self.latest_text, + "narrated_response_end": self.narrated_response_end, + "narration_segments": self.narration_segments, "tool_calls": self.tool_calls, "tool_results": self.tool_results, "is_thinking": self.is_thinking, diff --git a/EvoScientist/stream/utils.py b/EvoScientist/stream/utils.py index 5cd93d5..830bdab 100644 --- a/EvoScientist/stream/utils.py +++ b/EvoScientist/stream/utils.py @@ -7,6 +7,7 @@ adapted for deepagents tool names. import sys from enum import StrEnum +from functools import lru_cache from pathlib import PurePath # === Status marker constants === @@ -119,6 +120,23 @@ def _is_memory_path(path: str) -> bool: return normalized == "/memories" or normalized.startswith("/memories/") +@lru_cache(maxsize=1) +def _profile_memory_headings() -> tuple[str, ...]: + """Return profile headings from the canonical profile templates.""" + from EvoScientist.middleware.memory import PROFILE_TEMPLATES + + return tuple( + template.strip().splitlines()[0].strip() + for template in PROFILE_TEMPLATES.values() + if template.strip() + ) + + +def _looks_like_profile_memory(content: str) -> bool: + """Recognize profile-memory content when streamed without file args.""" + return any(heading in content for heading in _profile_memory_headings()) + + def format_tool_compact(name: str, args: dict | None) -> str: """Format as compact tool call string: ToolName(key_arg). @@ -141,19 +159,19 @@ def format_tool_compact(name: str, args: dict | None) -> str: # File operations (with special case for memory files) if name_lower == "read_file": path = _tool_path_arg(args) - if _is_memory_path(path) or path.endswith("/MEMORY.md") or path == "/MEMORY.md": + if _is_memory_path(path): return "Reading memory" return f"read_file({_shorten_path(path)})" if name_lower == "write_file": path = _tool_path_arg(args) - if _is_memory_path(path) or path.endswith("/MEMORY.md") or path == "/MEMORY.md": + if _is_memory_path(path): return "Updating memory" return f"write_file({_shorten_path(path)})" if name_lower == "edit_file": path = _tool_path_arg(args) - if _is_memory_path(path) or path.endswith("/MEMORY.md") or path == "/MEMORY.md": + if _is_memory_path(path): return "Updating memory" return f"edit_file({_shorten_path(path)})" @@ -249,15 +267,11 @@ def format_tool_compact_with_result( result_content = result_content or "" if name_lower in ("write_file", "edit_file"): - if ( - "/memories/" in result_content - or "/MEMORY.md" in result_content - or "MEMORY.md" in result_content - ): + if "/memories/" in result_content: return "Updating memory" elif name_lower == "read_file": path = _tool_path_arg(args) - if not path and "# EvoScientist Memory" in result_content: + if not path and _looks_like_profile_memory(result_content): return "Reading memory" return compact diff --git a/EvoScientist/subagents/_factory.py b/EvoScientist/subagents/_factory.py index 5d737d5..12714fd 100644 --- a/EvoScientist/subagents/_factory.py +++ b/EvoScientist/subagents/_factory.py @@ -10,7 +10,7 @@ runnable graph. Reuses the main EvoScientist agent's chat model, backend, and middleware so the deployed sub-agent has full capability parity with its in-process synchronous counterpart: same workspace files, same ``/skills/`` and -``/memory/`` routes, same error-handling and context-overflow middleware. +``/memories/`` routes, same error-handling and context-overflow middleware. """ from __future__ import annotations @@ -96,12 +96,8 @@ def build_async_subagent_graph(name: str) -> Any: # which uses ``interrupt()`` for the same purpose (waiting on a user # reply) and would deadlock an async sub-agent for the same reason. # - # Memory middleware is included so async sub-agents can READ - # /memory/MEMORY.md, but the extraction trigger (20+ human messages, - # see middleware/memory.py) never fires here — sub-agents only receive - # the parent's task delegation as a system prompt, not human messages. - # Net effect: sub-agents have read-only memory access. Memory writes - # happen exclusively from the main agent's user-facing conversation. + # Memory middleware is included so async sub-agents get the same profile + # context and `/memories/profile/...` file guidance as the main agent. return create_deep_agent( name=name, model=_ensure_chat_model(), diff --git a/tests/test_memory_merge.py b/tests/test_memory_merge.py deleted file mode 100644 index cf20c40..0000000 --- a/tests/test_memory_merge.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Tests for _merge_memory — backslash-safe regex replacement.""" - -import pytest - -from EvoScientist.middleware.memory import DEFAULT_MEMORY_TEMPLATE, _merge_memory - - -class TestMergeMemoryBackslashSafety: - """Ensure values containing regex-special sequences survive _merge_memory.""" - - def test_backslash_n_preserved(self): - """A value containing literal '\\n' must not become a newline.""" - extracted = { - "user_profile": {"name": r"C:\new_user"}, - } - result = _merge_memory(DEFAULT_MEMORY_TEMPLATE, extracted) - assert r"C:\new_user" in result - # The replacement must not introduce an actual newline inside the Name line - for line in result.splitlines(): - if "**Name**" in line: - assert r"C:\new_user" in line - break - else: - pytest.fail("Name line not found") - - def test_backreference_preserved(self): - r"""A value containing '\\1' must not be treated as a backreference.""" - extracted = { - "user_profile": {"role": r"A\1B"}, - } - result = _merge_memory(DEFAULT_MEMORY_TEMPLATE, extracted) - assert r"A\1B" in result - for line in result.splitlines(): - if "**Role**" in line: - assert r"A\1B" in line - break - else: - pytest.fail("Role line not found") - - def test_windows_path_preserved(self): - r"""A Windows-style path must survive without corruption.""" - extracted = { - "research_preferences": { - "preferred_frameworks": r"C:\path\to\file", - }, - } - result = _merge_memory(DEFAULT_MEMORY_TEMPLATE, extracted) - assert r"C:\path\to\file" in result - - def test_multiple_backslash_fields(self): - """Multiple fields with backslashes all survive.""" - extracted = { - "user_profile": { - "name": r"user\name", - "institution": r"MIT\Lab\42", - }, - "research_preferences": { - "hardware": r"GPU\0", - }, - } - result = _merge_memory(DEFAULT_MEMORY_TEMPLATE, extracted) - assert r"user\name" in result - assert r"MIT\Lab\42" in result - assert r"GPU\0" in result - - def test_plain_value_still_works(self): - """Sanity check: normal values without backslashes work fine.""" - extracted = { - "user_profile": {"name": "Alice", "role": "Researcher"}, - } - result = _merge_memory(DEFAULT_MEMORY_TEMPLATE, extracted) - assert "- **Name**: Alice" in result - assert "- **Role**: Researcher" in result diff --git a/tests/test_profile_memory_middleware.py b/tests/test_profile_memory_middleware.py new file mode 100644 index 0000000..975c78a --- /dev/null +++ b/tests/test_profile_memory_middleware.py @@ -0,0 +1,338 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from langchain_core.messages import SystemMessage + +import EvoScientist.middleware.memory as memory_module +from EvoScientist import paths + + +def _request(): + request = SimpleNamespace( + state={}, + runtime=object(), + system_message=SystemMessage(content="base system"), + ) + request.override = lambda **kwargs: SimpleNamespace( + **{ + "state": request.state, + "runtime": request.runtime, + "system_message": kwargs.get("system_message", request.system_message), + } + ) + return request + + +def _system_text(modified) -> str: + system_message = modified.system_message + assert system_message is not None + return str(system_message.content) + + +def _path_project_id(workspace) -> str: + return memory_module._resolve_project_id(workspace) + + +def _profile_texts(memories): + return [ + path.read_text(encoding="utf-8") + for path in (memories / "profile").rglob("*.md") + ] + + +def test_profile_memory_bootstraps_and_injects_profile_files(tmp_path, monkeypatch): + memories = tmp_path / "memories" + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.setattr(paths, "WORKSPACE_ROOT", workspace) + + middleware = memory_module.create_memory_middleware(str(memories)) + modified = middleware.modify_request(_request()) + system_text = _system_text(modified) + + assert "Today's date" not in system_text + assert "" in system_text + assert "# User profile" in system_text + assert "/memories/profile/USER_PROFILE.md" in system_text + assert (memories / "profile" / "SOUL.md").exists() + assert (memories / "profile" / "USER_PROFILE.md").exists() + assert (memories / "profile" / "RESEARCH_TASTE.md").exists() + assert list((memories / "profile" / "projects").glob("*/PROJECT_PROFILE.md")) + + +def test_profile_memory_uses_path_pointers_when_profiles_exceed_budget( + tmp_path, monkeypatch +): + memories = tmp_path / "memories" + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.setattr(paths, "WORKSPACE_ROOT", workspace) + + middleware = memory_module.create_memory_middleware( + str(memories), max_inline_profile_chars=10 + ) + modified = middleware.modify_request(_request()) + system_text = _system_text(modified) + + assert "Profile files are available at:" in system_text + assert "File: /memories/profile/SOUL.md" not in system_text + assert "/memories/profile/USER_PROFILE.md" in system_text + + +def test_profile_memory_async_path_bootstraps_and_injects( + tmp_path, monkeypatch, run_async +): + memories = tmp_path / "memories" + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.setattr(paths, "WORKSPACE_ROOT", workspace) + + async def _handler(request): + return request + + middleware = memory_module.create_memory_middleware(str(memories)) + modified = run_async(middleware.awrap_model_call(_request(), _handler)) + system_text = _system_text(modified) + + assert "" in system_text + assert "/memories/profile/USER_PROFILE.md" in system_text + assert (memories / "profile" / "USER_PROFILE.md").exists() + + +def test_profile_memory_write_failure_uses_path_pointers(tmp_path, monkeypatch): + memories = tmp_path / "memories" + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.setattr(paths, "WORKSPACE_ROOT", workspace) + + middleware = memory_module.create_memory_middleware(str(memories)) + monkeypatch.setattr(middleware, "_write_text", lambda _path, _content: False) + + modified = middleware.modify_request(_request()) + system_text = _system_text(modified) + + assert "Profile files are available at:" in system_text + assert "File: /memories/profile/SOUL.md" not in system_text + assert "# User profile" not in system_text + assert not (memories / "profile" / "USER_PROFILE.md").exists() + + +def test_profile_memory_read_failure_uses_path_pointers_without_overwriting( + tmp_path, monkeypatch +): + memories = tmp_path / "memories" + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.setattr(paths, "WORKSPACE_ROOT", workspace) + + profile_dir = memories / "profile" + profile_dir.mkdir(parents=True) + soul_path = profile_dir / "SOUL.md" + original_bytes = b"\xff\xfe\xfa existing profile bytes" + soul_path.write_bytes(original_bytes) + + middleware = memory_module.create_memory_middleware(str(memories)) + modified = middleware.modify_request(_request()) + system_text = _system_text(modified) + + assert "Profile files are available at:" in system_text + assert soul_path.read_bytes() == original_bytes + + +def test_profile_memory_migrates_legacy_memory_once(tmp_path, monkeypatch): + memories = tmp_path / "memories" + memories.mkdir() + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.setattr(paths, "WORKSPACE_ROOT", workspace) + (memories / "MEMORY.md").write_text( + "\n".join( + [ + "# EvoScientist Memory", + "", + "## User Profile", + "- **Name**: Alice", + "", + "## Research Preferences", + "- **Primary Domain**: RL", + "", + "## Experiment History", + "### [2026-01-01] Baseline", + "- **Conclusion**: Worked", + "", + "## Learned Preferences", + "- Prefers concise plans.", + ] + ), + encoding="utf-8", + ) + + middleware = memory_module.create_memory_middleware(str(memories)) + middleware.modify_request(_request()) + middleware.modify_request(_request()) + + user_profile = (memories / "profile" / "USER_PROFILE.md").read_text( + encoding="utf-8" + ) + research_taste = (memories / "profile" / "RESEARCH_TASTE.md").read_text( + encoding="utf-8" + ) + + assert user_profile.count("- **Name**: Alice") == 1 + assert user_profile.count("Prefers concise plans.") == 1 + assert user_profile.count("### Experiment History") == 1 + assert user_profile.count("- **Conclusion**: Worked") == 1 + assert research_taste.count("- **Primary Domain**: RL") == 1 + assert "Migrated from /memories/MEMORY.md" not in user_profile + assert "Migrated from /memories/MEMORY.md" not in research_taste + assert not (memories / "MEMORY.md").exists() + + +def test_profile_memory_deletes_blank_legacy_memory(tmp_path, monkeypatch): + memories = tmp_path / "memories" + memories.mkdir() + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.setattr(paths, "WORKSPACE_ROOT", workspace) + legacy_path = memories / "MEMORY.md" + legacy_path.write_text(" \n\n", encoding="utf-8") + + middleware = memory_module.create_memory_middleware(str(memories)) + middleware.modify_request(_request()) + + assert not legacy_path.exists() + + +def test_profile_memory_uses_explicit_workspace_for_project_profile( + tmp_path, monkeypatch +): + memories = tmp_path / "memories" + global_workspace = tmp_path / "global-workspace" + active_workspace = tmp_path / "active-workspace" + global_workspace.mkdir() + active_workspace.mkdir() + monkeypatch.setattr(paths, "WORKSPACE_ROOT", global_workspace) + + middleware = memory_module.create_memory_middleware( + str(memories), workspace_dir=str(active_workspace) + ) + modified = middleware.modify_request(_request()) + system_text = _system_text(modified) + + expected_project_id = _path_project_id(active_workspace) + wrong_project_id = _path_project_id(global_workspace) + + assert ( + f"/memories/profile/projects/{expected_project_id}/PROJECT_PROFILE.md" + in system_text + ) + assert ( + memories / "profile" / "projects" / expected_project_id / "PROJECT_PROFILE.md" + ).exists() + assert wrong_project_id not in system_text + assert not ( + memories / "profile" / "projects" / wrong_project_id / "PROJECT_PROFILE.md" + ).exists() + + +def test_profile_memory_resolves_project_id_once_per_middleware( + tmp_path, monkeypatch, run_async +): + memories = tmp_path / "memories" + workspace = tmp_path / "workspace" + workspace.mkdir() + calls = [] + + def _resolve_project_id(workspace_dir): + calls.append(workspace_dir) + return "P-cached-project" + + monkeypatch.setattr(memory_module, "_resolve_project_id", _resolve_project_id) + + middleware = memory_module.create_memory_middleware( + str(memories), workspace_dir=str(workspace), max_inline_profile_chars=10 + ) + sync_modified = middleware.modify_request(_request()) + async_modified = run_async(middleware.amodify_request(_request())) + + assert calls == [workspace] + assert ( + "/memories/profile/projects/P-cached-project/PROJECT_PROFILE.md" + in _system_text(sync_modified) + ) + assert ( + "/memories/profile/projects/P-cached-project/PROJECT_PROFILE.md" + in _system_text(async_modified) + ) + + +def test_profile_memory_preserves_unmapped_legacy_memory(tmp_path, monkeypatch): + memories = tmp_path / "memories" + memories.mkdir() + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.setattr(paths, "WORKSPACE_ROOT", workspace) + legacy_path = memories / "MEMORY.md" + custom_note = "Keep this custom deployment note." + legacy_path.write_text( + "\n".join( + [ + "# EvoScientist Memory", + "", + "## User Profile", + "- **Name**: Alice", + "", + "## Custom Notes", + custom_note, + ] + ), + encoding="utf-8", + ) + + middleware = memory_module.create_memory_middleware(str(memories)) + middleware.modify_request(_request()) + + user_profile = (memories / "profile" / "USER_PROFILE.md").read_text( + encoding="utf-8" + ) + assert custom_note in user_profile + assert not legacy_path.exists() + + +def test_profile_memory_skips_legacy_unknown_placeholders(tmp_path, monkeypatch): + memories = tmp_path / "memories" + memories.mkdir() + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.setattr(paths, "WORKSPACE_ROOT", workspace) + (memories / "MEMORY.md").write_text( + "\n".join( + [ + "# EvoScientist Memory", + "", + "## User Profile", + "- **Name**: (unknown)", + "- **Role**: (unknown)", + "", + "## Research Preferences", + "- **Primary Domain**: (unknown)", + "- **Preferred Methods**: (unknown)", + "", + "## Experiment History", + "(No experiments yet)", + "", + "## Learned Preferences", + "- (none yet)", + ] + ), + encoding="utf-8", + ) + + middleware = memory_module.create_memory_middleware(str(memories)) + middleware.modify_request(_request()) + + migrated_profile_text = "\n".join(_profile_texts(memories)) + assert "(unknown)" not in migrated_profile_text + assert "Imported from legacy MEMORY.md" not in migrated_profile_text + assert not (memories / "MEMORY.md").exists() diff --git a/tests/test_stream_display.py b/tests/test_stream_display.py index b983930..12dcb69 100644 --- a/tests/test_stream_display.py +++ b/tests/test_stream_display.py @@ -1,9 +1,22 @@ """Tests for Rich streaming display helpers.""" +from typing import Any, cast + +from rich.console import Console +from rich.markdown import Markdown + from EvoScientist.stream.display import ( _fix_markdown_heading_spacing, + create_streaming_display, resolve_final_status_footer, ) +from EvoScientist.stream.state import SubAgentState + + +def _render_text(renderable) -> str: + console = Console(record=True, width=100, color_system=None) + console.print(renderable) + return console.export_text() def test_resolve_final_status_footer_hides_footer_for_interactive_cli(): @@ -16,6 +29,416 @@ def test_resolve_final_status_footer_keeps_footer_for_noninteractive(): assert resolve_final_status_footer(False, lambda: "footer") == "footer" +def test_streaming_display_keeps_narration_visible_with_pending_memory_tool(): + """Profile-memory reads still block, while lead-in text remains visible.""" + narration = "Here is the answer." + renderable = create_streaming_display( + response_text=narration, + narrated_response_end=len(narration), + narration_segments=[(0, narration)], + tool_calls=[ + { + "id": "tc1", + "name": "read_file", + "args": {"path": "/memories/profile/USER_PROFILE.md"}, + } + ], + tool_results=[], + ) + + rendered = _render_text(renderable) + + assert "Here is the answer." in rendered + assert "Reading memory" in rendered + assert "Running" in rendered + assert rendered.index("Here is the answer.") < rendered.index("Reading memory") + + +def test_streaming_display_keeps_narration_visible_while_processing_tool_result(): + """Completed tools still block while their result is being processed.""" + narration = "Here is the answer." + renderable = create_streaming_display( + response_text=narration, + narrated_response_end=len(narration), + narration_segments=[(0, narration)], + tool_calls=[ + { + "id": "tc1", + "name": "read_file", + "args": {"path": "/memories/profile/USER_PROFILE.md"}, + } + ], + tool_results=[ + { + "name": "read_file", + "content": "# User profile\n\n- Likes concise updates.", + } + ], + is_processing=True, + ) + + rendered = _render_text(renderable) + + assert "Here is the answer." in rendered + assert "Reading memory" in rendered + assert "Analyzing results" in rendered + assert rendered.index("Here is the answer.") < rendered.index("Reading memory") + + +def test_streaming_display_keeps_narration_visible_with_pending_normal_tool(): + """Ordinary tools use the same pending-tool behavior as memory reads.""" + narration = "I will inspect the files first." + renderable = create_streaming_display( + response_text=narration, + narrated_response_end=len(narration), + narration_segments=[(0, narration)], + tool_calls=[ + { + "id": "tc1", + "name": "execute", + "args": {"command": "rg -n TODO ."}, + } + ], + tool_results=[], + ) + + rendered = _render_text(renderable) + + assert "execute(rg -n TODO .)" in rendered + assert "Running" in rendered + assert "I will inspect the files first." in rendered + assert rendered.index("I will inspect the files first.") < rendered.index( + "execute(rg -n TODO .)" + ) + + +def test_streaming_display_keeps_narration_separate_when_answer_streams(): + """Post-tool answers should not re-render pre-tool narration as Markdown.""" + narration = "I will inspect the files first.\n" + answer = "The delayed check completed." + renderable = create_streaming_display( + response_text=f"{narration}{answer}", + latest_text=answer, + narrated_response_end=len(narration), + narration_segments=[(0, narration)], + tool_calls=[ + { + "id": "tc1", + "name": "execute", + "args": {"command": "python check.py"}, + } + ], + tool_results=[ + { + "name": "execute", + "content": "check complete", + } + ], + response_markdown=Markdown("SHOULD NOT RENDER"), + ) + + rendered = _render_text(renderable) + + assert "I will inspect the files first." in rendered + assert "The delayed check completed." in rendered + assert "SHOULD NOT RENDER" not in rendered + assert rendered.index("I will inspect the files first.") < rendered.index( + "execute(python check.py)" + ) + assert rendered.index("execute(python check.py)") < rendered.index( + "The delayed check completed." + ) + + +def test_streaming_display_keeps_narration_separate_in_final_frame(): + """The final Rich frame should preserve narration without one concat block.""" + narration = "I will inspect the files first.\n" + answer = "The delayed check completed." + renderable = create_streaming_display( + response_text=f"{narration}{answer}", + latest_text=answer, + narrated_response_end=len(narration), + narration_segments=[(0, narration)], + tool_calls=[ + { + "id": "tc1", + "name": "execute", + "args": {"command": "python check.py"}, + } + ], + tool_results=[ + { + "name": "execute", + "content": "check complete", + } + ], + is_final=True, + response_markdown=Markdown("SHOULD NOT RENDER"), + ) + + rendered = _render_text(renderable) + + assert "I will inspect the files first." in rendered + assert "The delayed check completed." in rendered + assert "SHOULD NOT RENDER" not in rendered + assert rendered.index("I will inspect the files first.") < rendered.index( + "execute(python check.py)" + ) + assert rendered.index("execute(python check.py)") < rendered.index( + "The delayed check completed." + ) + + +def test_streaming_display_preserves_narration_for_pending_final_tool(): + """Stopped/error final frames keep narration attached to pending tools.""" + narration = "I will inspect the files first.\n" + answer = "[Stopped.]" + renderable = create_streaming_display( + response_text=f"{narration}{answer}", + latest_text=answer, + narrated_response_end=len(narration), + narration_segments=[(0, narration)], + tool_calls=[ + { + "id": "tc1", + "name": "execute", + "args": {"command": "sleep 30"}, + } + ], + tool_results=[], + is_final=True, + ) + + rendered = _render_text(renderable) + + assert "I will inspect the files first." in rendered + assert "execute(sleep 30)" in rendered + assert "[Stopped.]" in rendered + assert rendered.index("I will inspect the files first.") < rendered.index( + "execute(sleep 30)" + ) + assert rendered.index("execute(sleep 30)") < rendered.index("[Stopped.]") + + +def test_streaming_display_interleaves_multiple_narration_segments(): + """Multiple narrated segments should stay attached to their following tools.""" + first = "I will inspect the files first.\n" + second = "I found one file, now I will run it.\n" + answer = "The delayed check completed." + renderable = create_streaming_display( + response_text=f"{first}{second}{answer}", + latest_text=answer, + narrated_response_end=len(first) + len(second), + narration_segments=[ + (0, first), + (1, second), + ], + tool_calls=[ + { + "id": "tc1", + "name": "execute", + "args": {"command": "rg -n delayed ."}, + }, + { + "id": "tc2", + "name": "execute", + "args": {"command": "python check.py"}, + }, + ], + tool_results=[ + { + "name": "execute", + "content": "check.py", + }, + { + "name": "execute", + "content": "check complete", + }, + ], + is_final=True, + ) + + rendered = _render_text(renderable) + + assert rendered.index("I will inspect the files first.") < rendered.index( + "execute(rg -n delayed .)" + ) + assert rendered.index("execute(rg -n delayed .)") < rendered.index( + "I found one file, now I will run it." + ) + assert rendered.index("I found one file, now I will run it.") < rendered.index( + "execute(python check.py)" + ) + assert rendered.index("execute(python check.py)") < rendered.index( + "The delayed check completed." + ) + + +def test_streaming_display_preserves_narration_for_collapsed_completed_tool(): + """Live collapsed completed summaries keep narration from hidden tools.""" + narration = "I will check the early result.\n" + tool_calls = [ + { + "id": f"tc{i}", + "name": "execute", + "args": {"command": f"python step_{i}.py"}, + } + for i in range(5) + ] + tool_results = [ + { + "name": "execute", + "content": f"step {i} complete", + } + for i in range(5) + ] + + renderable = create_streaming_display( + response_text=narration, + narrated_response_end=len(narration), + narration_segments=[(0, narration)], + tool_calls=tool_calls, + tool_results=tool_results, + ) + + rendered = _render_text(renderable) + + assert "I will check the early result." in rendered + assert "1 completed" in rendered + assert "execute(python step_0.py)" not in rendered + assert rendered.index("I will check the early result.") < rendered.index( + "1 completed" + ) + + +def test_streaming_display_preserves_narration_for_collapsed_running_tool(): + """Live collapsed running summaries keep narration from hidden tools.""" + narration = "I will start the long-running check.\n" + tool_calls = [ + { + "id": f"tc{i}", + "name": "execute", + "args": {"command": f"sleep {i + 1}"}, + } + for i in range(4) + ] + + renderable = create_streaming_display( + response_text=narration, + narrated_response_end=len(narration), + narration_segments=[(0, narration)], + tool_calls=tool_calls, + tool_results=[], + ) + + rendered = _render_text(renderable) + + assert "I will start the long-running check." in rendered + assert "1 more running" in rendered + assert "execute(sleep 1)" not in rendered + assert rendered.index("I will start the long-running check.") < rendered.index( + "1 more running" + ) + + +def test_streaming_display_preserves_task_narration_while_subagent_runs(): + """Narration before a task call should stay attached to the task section.""" + narration = "I'll ask a specialist to inspect this.\n" + subagent = SubAgentState("code-agent", "inspect this") + subagent.is_active = True + subagent.add_tool_call("execute", {"command": "rg -n TODO ."}, "sa1") + + renderable = create_streaming_display( + response_text=narration, + narrated_response_end=len(narration), + narration_segments=[(0, narration)], + tool_calls=[ + { + "id": "task1", + "name": "task", + "args": { + "subagent_type": "code-agent", + "description": "inspect this", + }, + } + ], + tool_results=[], + subagents=[subagent], + ) + + rendered = _render_text(renderable) + + assert "I'll ask a specialist to inspect this." in rendered + assert "Cooking with code-agent" in rendered + assert "execute(rg -n TODO .)" in rendered + assert rendered.index("I'll ask a specialist to inspect this.") < rendered.index( + "Cooking with code-agent" + ) + + +def test_streaming_display_orders_final_task_narration_by_tool_index(): + """Task narration should not move after regular-tool narration in final frames.""" + first = "I'll ask a specialist to inspect this.\n" + second = "Now I will run the result locally.\n" + answer = "The local run passed." + subagent = SubAgentState("code-agent", "inspect this") + subagent.is_active = False + subagent.add_tool_call("execute", {"command": "rg -n TODO ."}, "sa1") + subagent.add_tool_result("execute", "todo.py", True, "sa1") + + renderable = create_streaming_display( + response_text=f"{first}{second}{answer}", + latest_text=answer, + narrated_response_end=len(first) + len(second), + narration_segments=[ + (0, first), + (1, second), + ], + tool_calls=[ + { + "id": "task1", + "name": "task", + "args": { + "subagent_type": "code-agent", + "description": "inspect this", + }, + }, + { + "id": "tc2", + "name": "execute", + "args": {"command": "python todo.py"}, + }, + ], + tool_results=[ + { + "name": "task", + "content": "todo.py", + }, + { + "name": "execute", + "content": "passed", + }, + ], + subagents=[subagent], + is_final=True, + ) + + rendered = _render_text(renderable) + + assert rendered.index("I'll ask a specialist to inspect this.") < rendered.index( + "Cooking with code-agent" + ) + assert rendered.index("Cooking with code-agent") < rendered.index( + "Now I will run the result locally." + ) + assert rendered.index("Now I will run the result locally.") < rendered.index( + "execute(python todo.py)" + ) + assert rendered.index("execute(python todo.py)") < rendered.index( + "The local run passed." + ) + + class TestFixMarkdownHeadingSpacing: """Pure-helper tests: heading levels, idempotence, EOS / CRLF / fenced code. The display-copy-only contract at call sites is covered by @@ -105,7 +528,7 @@ class TestAssistantMessageBufferContract: msg = AssistantMessage(initial_content=initial) fake_md = MagicMock() - msg.query_one = MagicMock(return_value=fake_md) + cast(Any, msg).query_one = MagicMock(return_value=fake_md) return msg, fake_md def test_flush_markdown_does_not_mutate_buffer(self): diff --git a/tests/test_stream_state.py b/tests/test_stream_state.py index 8629823..90f05c1 100644 --- a/tests/test_stream_state.py +++ b/tests/test_stream_state.py @@ -579,6 +579,37 @@ class TestLatestTextReset: # response_text still has everything assert state.response_text == "first segmentsecond segment" + def test_tool_call_marks_existing_text_as_narration(self): + state = StreamState() + state.handle_event({"type": "text", "content": "first segment"}) + state.handle_event( + {"type": "tool_call", "id": "tc1", "name": "execute", "args": {}} + ) + + assert state.narrated_response_end == len("first segment") + assert state.narration_segments == [(0, "first segment")] + + state.handle_event({"type": "text", "content": "second segment"}) + assert state.narrated_response_end == len("first segment") + assert state.narration_segments == [(0, "first segment")] + + def test_later_tool_call_extends_narrated_boundary(self): + state = StreamState() + state.handle_event({"type": "text", "content": "first segment"}) + state.handle_event( + {"type": "tool_call", "id": "tc1", "name": "execute", "args": {}} + ) + state.handle_event({"type": "text", "content": "second segment"}) + state.handle_event( + {"type": "tool_call", "id": "tc2", "name": "execute", "args": {}} + ) + + assert state.narrated_response_end == len("first segmentsecond segment") + assert state.narration_segments == [ + (0, "first segment"), + (1, "second segment"), + ] + # ============================================================================= # Name merging edge cases @@ -859,17 +890,6 @@ class TestHasPendingWork: state.handle_event({"type": "text", "content": "done"}) assert state.has_pending_work() is False - def test_internal_tool_ignored(self): - state = StreamState() - state.handle_event( - {"type": "tool_call", "id": "tc1", "name": "ExtractedMemory", "args": {}} - ) - state.handle_event( - {"type": "tool_result", "name": "ExtractedMemory", "content": "ok"} - ) - state.is_processing = False - assert state.has_pending_work() is False - class TestVisibleToolCounts: """Tests for StreamState.visible_tool_counts().""" @@ -893,29 +913,16 @@ class TestVisibleToolCounts: state.handle_event({"type": "tool_result", "name": "execute", "content": "ok"}) assert state.visible_tool_counts() == (1, 1) - def test_internal_tool_excluded(self): - state = StreamState() - state.handle_event( - {"type": "tool_call", "id": "tc1", "name": "ExtractedMemory", "args": {}} - ) - assert state.visible_tool_counts() == (0, 0) - def test_mixed(self): state = StreamState() state.handle_event( {"type": "tool_call", "id": "tc1", "name": "execute", "args": {}} ) - state.handle_event( - {"type": "tool_call", "id": "tc2", "name": "ExtractedMemory", "args": {}} - ) state.handle_event( {"type": "tool_call", "id": "tc3", "name": "search", "args": {}} ) state.handle_event({"type": "tool_result", "name": "execute", "content": "ok"}) - state.handle_event( - {"type": "tool_result", "name": "ExtractedMemory", "content": "ok"} - ) - # execute done, ExtractedMemory done but invisible, search pending + # execute done, search pending assert state.visible_tool_counts() == (1, 2) diff --git a/tests/test_stream_utils.py b/tests/test_stream_utils.py index 16628fc..12c365d 100644 --- a/tests/test_stream_utils.py +++ b/tests/test_stream_utils.py @@ -95,13 +95,17 @@ class TestFormatToolCompact: result = format_tool_compact("edit_file", {"path": "f.py"}) assert result == "edit_file(f.py)" - # Global memory file special display (/memories/ = global) + # Global profile memory display (/memories/ = global) def test_read_file_global_memory(self): - result = format_tool_compact("read_file", {"path": "/memories/MEMORY.md"}) + result = format_tool_compact( + "read_file", {"path": "/memories/profile/USER_PROFILE.md"} + ) assert result == "Reading memory" def test_read_file_global_memory_file_path_alias(self): - result = format_tool_compact("read_file", {"file_path": "/memories/MEMORY.md"}) + result = format_tool_compact( + "read_file", {"file_path": "/memories/profile/USER_PROFILE.md"} + ) assert result == "Reading memory" def test_read_file_any_global_memory_file(self): @@ -109,14 +113,15 @@ class TestFormatToolCompact: assert result == "Reading memory" def test_write_file_global_memory(self): - result = format_tool_compact("write_file", {"path": "/MEMORY.md"}) + result = format_tool_compact( + "write_file", {"path": "/memories/profile/USER_PROFILE.md"} + ) assert result == "Updating memory" - # Also covers paths with /memories/ prefix - result2 = format_tool_compact("write_file", {"path": "/memories/MEMORY.md"}) - assert result2 == "Updating memory" def test_edit_file_global_memory(self): - result = format_tool_compact("edit_file", {"path": "/memories/MEMORY.md"}) + result = format_tool_compact( + "edit_file", {"path": "/memories/profile/USER_PROFILE.md"} + ) assert result == "Updating memory" def test_write_edit_any_global_memory_file(self): @@ -144,14 +149,14 @@ class TestFormatToolCompact: read_result = format_tool_compact_with_result( "read_file", {}, - "# EvoScientist Memory\n\nFounder: Zachary", + "# User profile\n\nFounder: Zachary", ) assert read_result == "Reading memory" edit_result = format_tool_compact_with_result( "edit_file", {}, - "Successfully replaced 1 instance(s) of the string in '/memories/MEMORY.md'", + "Successfully replaced 1 instance(s) of the string in '/memories/profile/USER_PROFILE.md'", ) assert edit_result == "Updating memory" @@ -162,6 +167,28 @@ class TestFormatToolCompact: ) assert write_result == "Updating memory" + def test_profile_memory_inference_uses_profile_template_headings(self, monkeypatch): + from EvoScientist.middleware import memory + from EvoScientist.stream import utils + + monkeypatch.setitem( + memory.PROFILE_TEMPLATES, + "/profile/CUSTOM.md", + "# Custom profile\n\n## Notes\n", + ) + utils._profile_memory_headings.cache_clear() + + try: + result = format_tool_compact_with_result( + "read_file", + {}, + "# Custom profile\n\n- remembered", + ) + finally: + utils._profile_memory_headings.cache_clear() + + assert result == "Reading memory" + def test_project_memory_result_not_special(self): result = format_tool_compact_with_result( "write_file", diff --git a/tests/test_tui_widgets.py b/tests/test_tui_widgets.py index b590b39..5c208b8 100644 --- a/tests/test_tui_widgets.py +++ b/tests/test_tui_widgets.py @@ -170,6 +170,20 @@ class TestStoppedResponseText(unittest.TestCase): assert current == "partial\n[Stopped.]" assert final_text == "partial\n[Stopped.]" + def test_strips_trailing_placeholder_ellipsis(self): + from EvoScientist.cli.tui_interactive import ( + _strip_trailing_placeholder_ellipsis, + ) + + assert ( + _strip_trailing_placeholder_ellipsis("final answer\n...") == "final answer" + ) + assert _strip_trailing_placeholder_ellipsis("...") == "" + assert ( + _strip_trailing_placeholder_ellipsis("final answer\n...\n...") + == "final answer" + ) + @unittest.skipUnless(_has_textual, "textual not installed") class TestAssistantMessage(unittest.TestCase): @@ -225,9 +239,7 @@ class TestToolCallWidget(unittest.TestCase): from EvoScientist.cli.widgets.tool_call_widget import ToolCallWidget w = ToolCallWidget("edit_file", {}, "mem-1") - w._result_content = ( - "Successfully replaced 1 instance(s) of the string in '/memories/MEMORY.md'" - ) + w._result_content = "Successfully replaced 1 instance(s) of the string in '/memories/profile/USER_PROFILE.md'" class _Header: def __init__(self) -> None: @@ -510,15 +522,6 @@ class TestIsFinalResponse(unittest.TestCase): state.subagents = [sa] assert _is_final_response(state) is True - def test_internal_tools_ignored(self): - from EvoScientist.cli.tui_interactive import _is_final_response - from EvoScientist.stream.state import StreamState - - state = StreamState() - state.tool_calls = [{"name": "ExtractedMemory", "args": {}}] - # No result for internal tool -- should still be considered final - assert _is_final_response(state) is True - def test_processing_not_final(self): from EvoScientist.cli.tui_interactive import _is_final_response from EvoScientist.stream.state import StreamState