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