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