diff --git a/EvoScientist/EvoScientist.py b/EvoScientist/EvoScientist.py
index 212d4ba..26da08a 100644
--- a/EvoScientist/EvoScientist.py
+++ b/EvoScientist/EvoScientist.py
@@ -949,6 +949,8 @@ def _get_default_middleware(
MemoryObservationTarget.AGENT
),
memory_scheduler=memory_scheduler,
+ # First-contact intro: main agent only, and never in unattended runs.
+ enable_profile_bootstrap=not for_async_subagent and not bool(cfg.auto_mode),
)
# Main-agent tool selection may use the auxiliary model; async sub-agents
# keep the main model (they do real work, not a one-off helper call).
diff --git a/EvoScientist/middleware/memory.py b/EvoScientist/middleware/memory.py
index 05614a8..55cbdef 100644
--- a/EvoScientist/middleware/memory.py
+++ b/EvoScientist/middleware/memory.py
@@ -11,16 +11,22 @@ observation writes go through the structured ``record_observation`` tool.
from __future__ import annotations
import asyncio
+import contextlib
import logging
+import os
import re
+import tempfile
from collections.abc import Awaitable, Callable
from pathlib import Path
+from typing import Literal
+import yaml
from langchain.agents.middleware.types import (
AgentMiddleware,
ModelRequest,
ModelResponse,
)
+from langchain_core.messages import HumanMessage
from .. import paths as _paths
from ..memory import (
@@ -110,6 +116,233 @@ Do not record routine progress, raw traces, ordinary command output, citation
lists without synthesis, simple filesystem listings, temporary paths/run ids,
one-off environment discoveries, or task summaries."""
+_PROFILE_BOOTSTRAP_CONSENT = """\
+ — one small question (one `ask_user` call with a single multiple choice if
+that tool is available, otherwise in the same message): are they willing to
+spend a little time letting you get to know them better, so you can grow into
+their research assistant? Offer yes / later / no.
+- later, or they ignore the question → drop the subject and get to work; you
+ will ask again in a future session.
+- no → set `intro: skipped` in the frontmatter with `edit_file` and never
+ raise it again."""
+
+_PROFILE_BOOTSTRAP_CORE = """\
+Ask — with one `ask_user` call if that tool is available, otherwise in one
+plain message — for whatever of these you do not have yet:
+- How they would like to be addressed. A real name or a nickname both work; a
+ real name lets you find their published work later.
+- A homepage, Google Scholar, or GitHub link (optional).
+- Their field, as a multiple choice: Computer science / AI · Life sciences /
+ medicine · Physics / chemistry / materials · Social sciences / psychology ·
+ Mathematics / statistics · Not decided yet — new to research.
+Record the answers in `/memories/profile/USER_PROFILE.md` with `edit_file`: set
+`name:` (required) and `field:` / `homepage:` when given in the frontmatter,
+always double-quoting the value (`name: "…"`); put anything else stable they
+told you as bullets under the existing headings.
+If their answers invite it and they seem engaged, you may continue with one or
+two natural follow-up questions (current project, what they are stuck on, how
+they like reports) — conversationally, not as another form. Write anything
+stable into the profile. Stop as soon as they signal they want to get to work.
+Close briefly with how you will grow (their corrections →
+`/memories/profile/RESEARCH_TASTE.md`; failed runs and environment traps →
+observations; they can edit these files directly), then start their task.
+Do not search the web, read papers, or draft research taste in this turn."""
+
+PROFILE_BOOTSTRAP_FIRST = f"""
+
+This is your first exchange with this researcher: `USER_PROFILE.md` has no `name` yet.
+
+In this turn, in the user's language: introduce yourself in two or three
+sentences. If already holds notes about them, greet them as a
+returning collaborator, not a stranger. If their message is already a task,
+acknowledge it first and keep the whole opening shorter.
+
+Ask for consent before any survey{_PROFILE_BOOTSTRAP_CONSENT}
+Only after a yes, ask three things:
+{_PROFILE_BOOTSTRAP_CORE}
+If they brought no task, propose one concrete first task from what they told
+you instead; for someone new to research, offer to map the field together
+first. Keep the opening light.
+
+"""
+
+PROFILE_BOOTSTRAP_RETRY = f"""
+
+You have worked with this researcher for several sessions, but `USER_PROFILE.md`
+still has no `name`. Once, lightly, in the user's language, ask for consent
+again{_PROFILE_BOOTSTRAP_CONSENT}
+Only after a yes, continue as on first contact:
+{_PROFILE_BOOTSTRAP_CORE}
+Do not repeat the full introduction.
+
+"""
+
+_USER_PROFILE_PATH = "/profile/USER_PROFILE.md"
+_BOOKKEEPING_KEY = "evoscientist"
+_FRONTMATTER_RE = re.compile(r"\A---\r?\n(.*?)\r?\n---\r?\n?", re.DOTALL)
+
+USER_PROFILE_FRONTMATTER: dict[str, object] = {
+ "name": "",
+ "field": "",
+ "homepage": "",
+ "intro": "pending",
+ _BOOKKEEPING_KEY: {
+ "sessions": 0,
+ "intro_attempts": 0,
+ "last_thread": "",
+ "intro_asked_thread": "",
+ },
+}
+
+_USER_PROFILE_BODY = """# User profile
+
+Things worth remembering about the person using EvoScientist.
+
+## Stable facts
+
+## Preferences
+
+## Collaboration style
+
+## Constraints
+"""
+
+
+def _default_user_profile_frontmatter() -> dict[str, object]:
+ """Fresh copy of the default frontmatter (nested dict included)."""
+ return {
+ key: dict(value) if isinstance(value, dict) else value
+ for key, value in USER_PROFILE_FRONTMATTER.items()
+ }
+
+
+def _split_frontmatter(text: str) -> tuple[dict[str, object] | None, str]:
+ """Split a leading YAML frontmatter.
+
+ Returns ``({}, text)`` when no frontmatter block is present, and
+ ``(None, text)`` when a block is present but unparsable or not a mapping.
+ """
+ match = _FRONTMATTER_RE.match(text)
+ if match is None:
+ return {}, text
+ try:
+ meta = yaml.safe_load(match.group(1))
+ except yaml.YAMLError as e:
+ logger.debug("Ignoring malformed profile frontmatter: %s", e)
+ return None, text
+ if not isinstance(meta, dict):
+ return None, text
+ return meta, text[match.end() :]
+
+
+def _join_frontmatter(meta: dict[str, object], body: str) -> str:
+ dumped = yaml.safe_dump(meta, sort_keys=False, allow_unicode=True).rstrip("\n")
+ return f"---\n{dumped}\n---\n{body}"
+
+
+# Ask again on sessions 1, 2, 4, 8, ... — never gives up, but ever quieter.
+_BOOTSTRAP_MAX_EXPONENT = 62
+
+BootstrapVariant = Literal["first", "retry"]
+
+
+def _meta_str(value: object) -> str:
+ if isinstance(value, bool) or value is None:
+ return ""
+ if isinstance(value, (int, float)):
+ return str(value)
+ return value.strip() if isinstance(value, str) else ""
+
+
+def _meta_int(value: object) -> int:
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
+ return 0
+ return value
+
+
+def _bootstrap_view(meta: dict[str, object]) -> dict[str, object]:
+ """Bootstrap-relevant fields of a profile frontmatter with defaults applied."""
+ book = meta.get(_BOOKKEEPING_KEY)
+ if not isinstance(book, dict):
+ book = {}
+ return {
+ "name": _meta_str(meta.get("name")),
+ "intro": _meta_str(meta.get("intro")) or "pending",
+ "sessions": _meta_int(book.get("sessions")),
+ "intro_attempts": _meta_int(book.get("intro_attempts")),
+ "last_thread": _meta_str(book.get("last_thread")),
+ "intro_asked_thread": _meta_str(book.get("intro_asked_thread")),
+ }
+
+
+def _bootstrap_decision(
+ view: dict[str, object],
+ *,
+ thread_id: str | None,
+ human_messages: int,
+) -> BootstrapVariant | None:
+ """Which first-contact block (if any) this model call should carry."""
+ if view["name"] or view["intro"] == "skipped":
+ return None
+ if human_messages != 1:
+ return None
+ if thread_id is None:
+ return "first"
+ if view["intro_asked_thread"] == thread_id:
+ return "first" if view["intro_attempts"] <= 1 else "retry"
+ attempts = min(view["intro_attempts"], _BOOTSTRAP_MAX_EXPONENT)
+ if view["sessions"] >= 1 << attempts:
+ return "first" if attempts == 0 else "retry"
+ return None
+
+
+def _apply_bootstrap_view(
+ meta: dict[str, object], view: dict[str, object]
+) -> dict[str, object]:
+ """Merge bookkeeping from *view* into *meta*, filling missing identity keys."""
+ merged = _default_user_profile_frontmatter()
+ merged.update({k: v for k, v in meta.items() if k != _BOOKKEEPING_KEY})
+ book = meta.get(_BOOKKEEPING_KEY)
+ merged[_BOOKKEEPING_KEY] = {
+ **(book if isinstance(book, dict) else {}),
+ "sessions": view["sessions"],
+ "intro_attempts": view["intro_attempts"],
+ "last_thread": view["last_thread"],
+ "intro_asked_thread": view["intro_asked_thread"],
+ }
+ return merged
+
+
+def _current_thread_id() -> str | None:
+ """Thread id of the running graph, or None outside a runnable context."""
+ try:
+ from langgraph.config import get_config
+
+ config = get_config()
+ except Exception:
+ return None
+ if not isinstance(config, dict):
+ return None
+ configurable = config.get("configurable") or {}
+ if not isinstance(configurable, dict):
+ return None
+ thread_id = configurable.get("thread_id")
+ return thread_id if isinstance(thread_id, str) and thread_id else None
+
+
+def _count_human_messages(state: object) -> int:
+ messages = state.get("messages") if isinstance(state, dict) else None
+ if not isinstance(messages, (list, tuple)):
+ return 0
+ # Skip synthetic HumanMessages (e.g. summarization) — not the user's turn.
+ return sum(
+ 1
+ for message in messages
+ if isinstance(message, HumanMessage)
+ and message.additional_kwargs.get("lc_source") is None
+ )
+
+
PROFILE_TEMPLATES: dict[str, str] = {
"/profile/SOUL.md": """# EvoScientist soul
@@ -121,18 +354,9 @@ Default behavior for this copy of EvoScientist.
## 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/USER_PROFILE.md": _join_frontmatter(
+ _default_user_profile_frontmatter(), _USER_PROFILE_BODY
+ ),
"/profile/RESEARCH_TASTE.md": """# Research taste
Research taste to keep in mind: interests, standards, methods that tend to fit, and things to avoid.
@@ -240,12 +464,14 @@ class EvoMemoryMiddleware(AgentMiddleware):
enable_observation_memory: bool = True,
enable_observation_tool: bool = True,
memory_scheduler: MemoryScheduler | None = None,
+ enable_profile_bootstrap: bool = False,
) -> None:
self._memory_dir = Path(memory_dir).expanduser()
workspace = Path(workspace_dir or _paths.WORKSPACE_ROOT).expanduser()
self._workspace_dir = workspace
self._project_id = resolve_project_id(workspace)
self._enable_profile_memory = enable_profile_memory
+ self._enable_profile_bootstrap = enable_profile_bootstrap
self._enable_observation_memory = enable_observation_memory
self._memory_scheduler = memory_scheduler
self._profile_specs = _profile_specs(self._project_id)
@@ -330,12 +556,22 @@ class EvoMemoryMiddleware(AgentMiddleware):
raise
def _write_text(self, path: Path, content: str) -> bool:
- """Write UTF-8 text, creating parent directories as needed."""
+ """Write UTF-8 text atomically, creating parent directories as needed."""
+ tmp_path: Path | None = None
try:
path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text(content, encoding="utf-8")
+ fd, tmp_name = tempfile.mkstemp(
+ dir=path.parent, prefix=f".{path.name}.", suffix=".tmp"
+ )
+ tmp_path = Path(tmp_name)
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
+ handle.write(content)
+ os.replace(tmp_path, path)
except OSError as e:
logger.warning("Failed to write profile memory %s: %s", path, e)
+ if tmp_path is not None:
+ with contextlib.suppress(OSError):
+ tmp_path.unlink()
return False
return True
@@ -367,6 +603,16 @@ class EvoMemoryMiddleware(AgentMiddleware):
if not self._write_text(path, template):
raise OSError(f"Failed to bootstrap profile file: {path}")
content = template
+ elif (
+ memory_path == _USER_PROFILE_PATH
+ and content.strip()
+ and _FRONTMATTER_RE.match(content) is None
+ ):
+ # Pre-frontmatter profiles: prepend defaults, keep the body verbatim.
+ content = _join_frontmatter(
+ _default_user_profile_frontmatter(), content
+ )
+ self._write_text(path, content)
records.append((memory_path, content))
return records
@@ -468,6 +714,51 @@ class EvoMemoryMiddleware(AgentMiddleware):
logger.debug("Failed to read profile memory: %s", e)
return self._profile_pointer_context
+ def _bootstrap_context(self, *, thread_id: str | None, human_messages: int) -> str:
+ """First-contact block for this call; also bumps the frontmatter bookkeeping."""
+ if not (self._enable_profile_memory and self._enable_profile_bootstrap):
+ return ""
+ path = self._file_path(_USER_PROFILE_PATH)
+ try:
+ content = self._read_text(path)
+ except Exception as e:
+ logger.debug("Skipping profile bootstrap: %s", e)
+ return ""
+ # Empty means a concurrent writer is mid-truncate: never replace it.
+ if content is None or not content.strip():
+ return ""
+
+ meta, body = _split_frontmatter(content)
+ if meta is None:
+ logger.warning("Unparsable frontmatter in %s; skipping bootstrap", path)
+ return ""
+ view = _bootstrap_view(meta)
+ dirty = False
+ if thread_id is not None and view["last_thread"] != thread_id:
+ view["sessions"] += 1
+ view["last_thread"] = thread_id
+ dirty = True
+ variant = _bootstrap_decision(
+ view, thread_id=thread_id, human_messages=human_messages
+ )
+ if (
+ variant is not None
+ and thread_id is not None
+ and view["intro_asked_thread"] != thread_id
+ ):
+ view["intro_attempts"] += 1
+ view["intro_asked_thread"] = thread_id
+ dirty = True
+ if dirty:
+ merged = _apply_bootstrap_view(meta, view)
+ self._write_text(path, _join_frontmatter(merged, body))
+
+ if variant == "first":
+ return PROFILE_BOOTSTRAP_FIRST
+ if variant == "retry":
+ return PROFILE_BOOTSTRAP_RETRY
+ return ""
+
def _refresh_observation_index_context(self) -> str:
"""Refresh the prompt observation index from current memory files."""
if not self._enable_observation_memory:
@@ -534,6 +825,7 @@ class EvoMemoryMiddleware(AgentMiddleware):
*,
observation_index_context: str,
profile_content: str,
+ bootstrap_context: str = "",
) -> str:
"""Build request memory context ordered from static to dynamic."""
return "\n\n".join(
@@ -542,6 +834,7 @@ class EvoMemoryMiddleware(AgentMiddleware):
self._memory_instructions_context(),
observation_index_context,
self._profile_memory_context(profile_content),
+ bootstrap_context.strip(),
)
if part
)
@@ -552,6 +845,7 @@ class EvoMemoryMiddleware(AgentMiddleware):
*,
observation_index_context: str,
profile_content: str,
+ bootstrap_context: str = "",
) -> ModelRequest:
"""Append memory context and editing guidance to the system prompt."""
if not self._enable_profile_memory and not self._enable_observation_memory:
@@ -560,6 +854,7 @@ class EvoMemoryMiddleware(AgentMiddleware):
injection = self._memory_context_for_request(
observation_index_context=observation_index_context,
profile_content=profile_content,
+ bootstrap_context=bootstrap_context,
)
new_system = append_to_system_message(request.system_message, injection)
return request.override(system_message=new_system)
@@ -571,16 +866,25 @@ class EvoMemoryMiddleware(AgentMiddleware):
def modify_request(self, request: ModelRequest) -> ModelRequest:
"""Apply memory injection for synchronous model calls."""
+ profile_content = self._profile_context_for_request()
return self._inject_memory_context(
request,
observation_index_context=self._refresh_observation_index_context(),
- profile_content=self._profile_context_for_request(),
+ profile_content=profile_content,
+ bootstrap_context=self._bootstrap_context(
+ thread_id=_current_thread_id(),
+ human_messages=_count_human_messages(request.state),
+ ),
)
async def amodify_request(self, request: ModelRequest) -> ModelRequest:
"""Apply memory injection for asynchronous model calls."""
observation_index_context = ""
profile_context = ""
+ bootstrap_context = ""
+ # Resolved on the event-loop thread: get_config() reads a contextvar.
+ thread_id = _current_thread_id()
+ human_messages = _count_human_messages(request.state)
if self._enable_observation_memory and self._enable_profile_memory:
observation_index_context, profile_context = await asyncio.gather(
@@ -594,10 +898,19 @@ class EvoMemoryMiddleware(AgentMiddleware):
elif self._enable_profile_memory:
profile_context = await asyncio.to_thread(self._read_profile_memory)
+ # After the profile read so the file exists on a brand-new memory dir.
+ if self._enable_profile_memory and self._enable_profile_bootstrap:
+ bootstrap_context = await asyncio.to_thread(
+ self._bootstrap_context,
+ thread_id=thread_id,
+ human_messages=human_messages,
+ )
+
return self._inject_memory_context(
request,
observation_index_context=observation_index_context,
profile_content=profile_context,
+ bootstrap_context=bootstrap_context,
)
def wrap_model_call(
@@ -627,6 +940,7 @@ def create_memory_middleware(
enable_observation_memory: bool = True,
enable_observation_tool: bool = True,
memory_scheduler: MemoryScheduler | None = None,
+ enable_profile_bootstrap: bool = False,
) -> EvoMemoryMiddleware:
"""Build profile-memory middleware, defaulting to the shared memories directory."""
@@ -643,4 +957,5 @@ def create_memory_middleware(
enable_observation_memory=enable_observation_memory,
enable_observation_tool=enable_observation_tool,
memory_scheduler=memory_scheduler,
+ enable_profile_bootstrap=enable_profile_bootstrap,
)
diff --git a/EvoScientist/stream/utils.py b/EvoScientist/stream/utils.py
index f01f12b..0eaa786 100644
--- a/EvoScientist/stream/utils.py
+++ b/EvoScientist/stream/utils.py
@@ -125,11 +125,16 @@ 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()
- )
+ # First markdown heading, so a leading YAML frontmatter is skipped.
+ headings = []
+ for template in PROFILE_TEMPLATES.values():
+ heading = next(
+ (line.strip() for line in template.splitlines() if line.startswith("# ")),
+ "",
+ )
+ if heading:
+ headings.append(heading)
+ return tuple(headings)
def _looks_like_profile_memory(content: str) -> bool:
diff --git a/tests/test_profile_memory_middleware.py b/tests/test_profile_memory_middleware.py
index 79e2c45..080d491 100644
--- a/tests/test_profile_memory_middleware.py
+++ b/tests/test_profile_memory_middleware.py
@@ -2,6 +2,7 @@ from __future__ import annotations
import threading
from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
from blockbuster import BlockBuster
from langchain_core.messages import SystemMessage
@@ -752,3 +753,676 @@ def test_profile_memory_skips_legacy_unknown_placeholders(tmp_path, monkeypatch)
assert "(unknown)" not in migrated_profile_text
assert "Imported from legacy MEMORY.md" not in migrated_profile_text
assert not (memories / "MEMORY.md").exists()
+
+
+# ---- profile bootstrap: frontmatter helpers ---------------------------------
+
+
+def test_split_frontmatter_round_trips_and_preserves_body():
+ meta = memory_module._default_user_profile_frontmatter()
+ body = "# User profile\n\n## Stable facts\n- remembered\n"
+
+ text = memory_module._join_frontmatter(meta, body)
+ parsed_meta, parsed_body = memory_module._split_frontmatter(text)
+
+ # Pins the on-disk form the agent's edit_file targets.
+ assert text.startswith("---\nname: ''\n")
+ assert parsed_meta == meta
+ assert parsed_body == body
+
+
+def test_split_frontmatter_without_block_returns_text_unchanged():
+ text = "# User profile\n\n- remembered\n"
+
+ assert memory_module._split_frontmatter(text) == ({}, text)
+
+
+def test_split_frontmatter_malformed_yaml_returns_none_meta():
+ text = "---\nname: [unclosed\n---\n# User profile\n"
+
+ assert memory_module._split_frontmatter(text) == (None, text)
+
+
+def test_split_frontmatter_non_mapping_returns_none_meta():
+ text = "---\n- just a list\n---\n# User profile\n"
+
+ assert memory_module._split_frontmatter(text) == (None, text)
+
+
+def test_user_profile_template_starts_with_default_frontmatter():
+ template = memory_module.PROFILE_TEMPLATES["/profile/USER_PROFILE.md"]
+ meta, body = memory_module._split_frontmatter(template)
+
+ assert meta == memory_module._default_user_profile_frontmatter()
+ assert meta["name"] == ""
+ assert meta["intro"] == "pending"
+ assert meta["evoscientist"] == {
+ "sessions": 0,
+ "intro_attempts": 0,
+ "last_thread": "",
+ "intro_asked_thread": "",
+ }
+ assert body.startswith("# User profile\n")
+ assert "## Constraints" in body
+
+
+# ---- profile bootstrap: atomic writes ---------------------------------------
+
+
+def test_write_text_is_atomic_and_leaves_no_tmp_sibling(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))
+ target = memories / "profile" / "SOUL.md"
+
+ assert middleware._write_text(target, "content") is True
+
+ assert target.read_text(encoding="utf-8") == "content"
+ assert list(target.parent.glob(".*.tmp")) == []
+
+
+def test_write_text_failure_leaves_original_untouched_and_no_tmp_sibling(
+ 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))
+ target = memories / "profile" / "SOUL.md"
+ target.parent.mkdir(parents=True)
+ target.write_text("original", encoding="utf-8")
+
+ def _boom(*_a, **_kw):
+ raise OSError("boom")
+
+ monkeypatch.setattr(memory_module.os, "replace", _boom)
+
+ assert middleware._write_text(target, "new content") is False
+
+ assert target.read_text(encoding="utf-8") == "original"
+ assert list(target.parent.glob(".*.tmp")) == []
+
+
+# ---- profile bootstrap: migration ------------------------------------------
+
+
+def _user_profile_meta(memories):
+ text = (memories / "profile" / "USER_PROFILE.md").read_text(encoding="utf-8")
+ return memory_module._split_frontmatter(text)
+
+
+def test_existing_user_profile_without_frontmatter_gets_one_with_body_verbatim(
+ 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)
+ body = "# User profile\n\n## Preferences\n- Likes short reports\n"
+ (profile_dir / "USER_PROFILE.md").write_text(body, encoding="utf-8")
+
+ middleware = memory_module.create_memory_middleware(str(memories))
+ middleware.modify_request(_request())
+
+ meta, parsed_body = _user_profile_meta(memories)
+ assert meta == memory_module._default_user_profile_frontmatter()
+ assert parsed_body == body
+
+
+def test_existing_user_profile_with_frontmatter_is_left_alone(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)
+ original = "---\nname: Ada\nintro: pending\n---\n# User profile\n"
+ (profile_dir / "USER_PROFILE.md").write_text(original, encoding="utf-8")
+
+ middleware = memory_module.create_memory_middleware(str(memories))
+ middleware.modify_request(_request())
+
+ assert (profile_dir / "USER_PROFILE.md").read_text(encoding="utf-8") == original
+
+
+def test_write_text_swallows_cleanup_errors_and_returns_false(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))
+ target = memories / "profile" / "USER_PROFILE.md"
+ target.parent.mkdir(parents=True)
+ target.write_text("original", encoding="utf-8")
+
+ def _denied(*_args, **_kwargs):
+ raise PermissionError("locked")
+
+ monkeypatch.setattr(memory_module.os, "replace", _denied)
+ monkeypatch.setattr(memory_module.Path, "unlink", _denied)
+
+ assert middleware._write_text(target, "new") is False
+ assert target.read_text(encoding="utf-8") == "original"
+
+
+def test_bootstrap_never_replaces_an_empty_user_profile(tmp_path, monkeypatch):
+ memories = tmp_path / "memories"
+ profile_dir = memories / "profile"
+ profile_dir.mkdir(parents=True)
+ (profile_dir / "USER_PROFILE.md").write_text("", encoding="utf-8")
+ _, middleware = _bootstrap_middleware(tmp_path, monkeypatch)
+
+ system = _system(middleware.modify_request(_bootstrap_request()))
+
+ assert "" not in system
+ assert (profile_dir / "USER_PROFILE.md").read_text(encoding="utf-8") == ""
+
+
+def test_ensure_profile_files_does_not_migrate_empty_user_profile(
+ 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)
+ (profile_dir / "USER_PROFILE.md").write_text("", encoding="utf-8")
+
+ middleware = memory_module.create_memory_middleware(str(memories))
+ middleware.modify_request(_request())
+
+ assert (profile_dir / "USER_PROFILE.md").read_text(encoding="utf-8") == ""
+
+
+# ---- profile bootstrap: decision --------------------------------------------
+
+
+def _view(**overrides):
+ view = {
+ "name": "",
+ "intro": "pending",
+ "sessions": 1,
+ "intro_attempts": 0,
+ "last_thread": "t1",
+ "intro_asked_thread": "",
+ }
+ view.update(overrides)
+ return view
+
+
+def _decide(view, *, thread_id="t1", human_messages=1):
+ return memory_module._bootstrap_decision(
+ view, thread_id=thread_id, human_messages=human_messages
+ )
+
+
+def test_bootstrap_decision_first_session_first_turn():
+ assert _decide(_view()) == "first"
+
+
+def test_bootstrap_decision_none_once_name_is_set():
+ assert _decide(_view(name="Ada")) is None
+
+
+def test_bootstrap_decision_none_once_skipped():
+ assert _decide(_view(intro="skipped")) is None
+
+
+def test_bootstrap_decision_none_after_first_turn():
+ assert _decide(_view(), human_messages=2) is None
+ assert _decide(_view(), human_messages=0) is None
+
+
+def test_bootstrap_decision_without_thread_id_is_first():
+ assert _decide(_view(intro_attempts=1, sessions=9), thread_id=None) == "first"
+
+
+def test_bootstrap_decision_same_thread_keeps_variant_during_first_turn():
+ asked = _view(intro_attempts=1, intro_asked_thread="t1")
+ assert _decide(asked) == "first"
+ retried = _view(intro_attempts=2, intro_asked_thread="t1", sessions=5)
+ assert _decide(retried) == "retry"
+
+
+def test_bootstrap_decision_retry_follows_exponential_backoff():
+ assert _decide(_view(intro_attempts=1, sessions=1)) is None
+ assert _decide(_view(intro_attempts=1, sessions=2, last_thread="t2")) == "retry"
+ assert _decide(_view(intro_attempts=2, sessions=3, last_thread="t3")) is None
+ assert _decide(_view(intro_attempts=2, sessions=4, last_thread="t4")) == "retry"
+ assert _decide(_view(intro_attempts=3, sessions=7, last_thread="t7")) is None
+ assert _decide(_view(intro_attempts=3, sessions=8, last_thread="t8")) == "retry"
+
+
+def test_bootstrap_decision_never_gives_up_but_caps_the_exponent():
+ assert _decide(_view(intro_attempts=4, sessions=15, last_thread="t15")) is None
+ assert _decide(_view(intro_attempts=4, sessions=16, last_thread="t16")) == "retry"
+ # A corrupted attempt count must not build a huge exponent.
+ assert _decide(_view(intro_attempts=10**9, sessions=99, last_thread="t99")) is None
+
+
+def test_bootstrap_view_fills_defaults_and_ignores_bad_types():
+ view = memory_module._bootstrap_view(
+ {
+ "name": 42,
+ "intro": None,
+ "evoscientist": {"sessions": "3", "intro_attempts": True},
+ }
+ )
+ assert view == {
+ "name": "42",
+ "intro": "pending",
+ "sessions": 0,
+ "intro_attempts": 0,
+ "last_thread": "",
+ "intro_asked_thread": "",
+ }
+ assert memory_module._bootstrap_view({})["intro"] == "pending"
+
+
+def test_apply_bootstrap_view_keeps_identity_keys_and_extra_keys():
+ meta = {"name": "Ada", "publications_checked": "2026-09-01"}
+ view = _view(name="Ada", sessions=2, last_thread="t2")
+
+ merged = memory_module._apply_bootstrap_view(meta, view)
+
+ assert merged["name"] == "Ada"
+ assert merged["field"] == ""
+ assert merged["intro"] == "pending"
+ assert merged["publications_checked"] == "2026-09-01"
+ assert merged["evoscientist"] == {
+ "sessions": 2,
+ "intro_attempts": 0,
+ "last_thread": "t2",
+ "intro_asked_thread": "",
+ }
+ assert list(merged)[:5] == ["name", "field", "homepage", "intro", "evoscientist"]
+
+
+# ---- profile bootstrap: prompts ---------------------------------------------
+
+
+def test_bootstrap_prompts_are_tagged():
+ first = memory_module.PROFILE_BOOTSTRAP_FIRST
+ retry = memory_module.PROFILE_BOOTSTRAP_RETRY
+
+ for block in (first, retry):
+ assert block.strip().startswith("")
+ assert block.strip().endswith("")
+ assert "`edit_file`" in block
+ assert "intro: skipped" in block
+ assert "`ask_user`" in first
+ assert "Do not search the web" in first
+ assert "always double-quoting the value" in first
+ assert "Ask for consent before any survey" in first
+ assert "spend a little time letting you get to" in first
+ assert "continue as on first contact" in retry
+ assert memory_module._PROFILE_BOOTSTRAP_CORE in first
+ assert memory_module._PROFILE_BOOTSTRAP_CORE in retry
+ assert memory_module._PROFILE_BOOTSTRAP_CONSENT in first
+ assert memory_module._PROFILE_BOOTSTRAP_CONSENT in retry
+ assert "yes / later / no" in first
+ assert first.index("Ask for consent") < first.index("ask three things")
+ assert "follow-up" in first
+ assert "Do not repeat the full introduction" in retry
+ assert "spend a little time letting you get to" in retry
+ assert "yes / later / no" in retry
+ assert retry.index("ask for consent") < retry.index("continue as on first contact")
+ assert "frontmatter" not in memory_module.PROFILE_MEMORY_INSTRUCTIONS
+
+
+# ---- profile bootstrap: middleware wiring -----------------------------------
+
+
+def _bootstrap_request(human_messages: int = 1):
+ from langchain_core.messages import HumanMessage
+
+ request = _request()
+ request.state = {
+ "messages": [HumanMessage(content=f"m{i}") for i in range(human_messages)]
+ }
+ return request
+
+
+def _bootstrap_middleware(tmp_path, monkeypatch, *, thread_id="t1", **kwargs):
+ memories = tmp_path / "memories"
+ workspace = tmp_path / "workspace"
+ workspace.mkdir(exist_ok=True)
+ monkeypatch.setattr(paths, "WORKSPACE_ROOT", workspace)
+ monkeypatch.setattr(memory_module, "_current_thread_id", lambda: thread_id)
+ kwargs.setdefault("enable_profile_bootstrap", True)
+ return memories, memory_module.create_memory_middleware(str(memories), **kwargs)
+
+
+def _system(modified) -> str:
+ content = modified.system_message.content
+ if isinstance(content, str):
+ return content
+ return "\n".join(
+ block.get("text", "") for block in content if isinstance(block, dict)
+ )
+
+
+def test_bootstrap_block_injected_last_on_fresh_first_turn(tmp_path, monkeypatch):
+ _memories, middleware = _bootstrap_middleware(tmp_path, monkeypatch)
+
+ system = _system(middleware.modify_request(_bootstrap_request()))
+
+ assert "" in system
+ assert "first exchange with this researcher" in system
+ assert system.index("") < system.index("")
+ assert system.rstrip().endswith("")
+
+
+def test_bootstrap_block_absent_by_default(tmp_path, monkeypatch):
+ _, middleware = _bootstrap_middleware(
+ tmp_path, monkeypatch, enable_profile_bootstrap=False
+ )
+
+ system = _system(middleware.modify_request(_bootstrap_request()))
+
+ assert "" not in system
+ meta, _ = _user_profile_meta(tmp_path / "memories")
+ assert meta["evoscientist"]["sessions"] == 0
+
+
+def test_bootstrap_block_absent_after_first_turn(tmp_path, monkeypatch):
+ _, middleware = _bootstrap_middleware(tmp_path, monkeypatch)
+
+ system = _system(middleware.modify_request(_bootstrap_request(human_messages=2)))
+
+ assert "" not in system
+
+
+def test_bootstrap_block_absent_once_name_or_skip_is_written(tmp_path, monkeypatch):
+ memories, middleware = _bootstrap_middleware(tmp_path, monkeypatch)
+ middleware.modify_request(_bootstrap_request())
+ profile = memories / "profile" / "USER_PROFILE.md"
+
+ profile.write_text(
+ profile.read_text(encoding="utf-8").replace("name: ''", "name: Ada", 1),
+ encoding="utf-8",
+ )
+ assert "" not in _system(
+ middleware.modify_request(_bootstrap_request())
+ )
+
+ profile.write_text(
+ profile.read_text(encoding="utf-8")
+ .replace("name: Ada", "name: ''", 1)
+ .replace("intro: pending", "intro: skipped", 1),
+ encoding="utf-8",
+ )
+ assert "" not in _system(
+ middleware.modify_request(_bootstrap_request())
+ )
+
+
+def test_bootstrap_bookkeeping_counts_sessions_and_attempts_once_per_thread(
+ tmp_path, monkeypatch
+):
+ memories, middleware = _bootstrap_middleware(tmp_path, monkeypatch)
+ body_before = memory_module._split_frontmatter(
+ memory_module.PROFILE_TEMPLATES["/profile/USER_PROFILE.md"]
+ )[1]
+
+ middleware.modify_request(_bootstrap_request())
+ middleware.modify_request(_bootstrap_request()) # same thread, ask_user resume
+ meta, body = _user_profile_meta(memories)
+
+ assert body == body_before
+ assert meta["evoscientist"] == {
+ "sessions": 1,
+ "intro_attempts": 1,
+ "last_thread": "t1",
+ "intro_asked_thread": "t1",
+ }
+
+ monkeypatch.setattr(memory_module, "_current_thread_id", lambda: "t2")
+ system = _system(middleware.modify_request(_bootstrap_request()))
+ middleware.modify_request(_bootstrap_request()) # same thread: no double bump
+ meta, _ = _user_profile_meta(memories)
+
+ assert "still has no `name`" in system
+ assert meta["evoscientist"]["sessions"] == 2
+ assert meta["evoscientist"]["intro_attempts"] == 2
+
+
+def test_bootstrap_retries_with_exponential_backoff(tmp_path, monkeypatch):
+ memories, middleware = _bootstrap_middleware(tmp_path, monkeypatch)
+ middleware.modify_request(_bootstrap_request()) # session 1: first ask
+
+ monkeypatch.setattr(memory_module, "_current_thread_id", lambda: "t2")
+ system = _system(middleware.modify_request(_bootstrap_request()))
+ meta, _ = _user_profile_meta(memories)
+
+ assert "still has no `name`" in system
+ assert "first exchange with this researcher" not in system
+ assert meta["evoscientist"]["intro_attempts"] == 2
+ assert meta["evoscientist"]["intro_asked_thread"] == "t2"
+
+ monkeypatch.setattr(memory_module, "_current_thread_id", lambda: "t3")
+ assert "" not in _system(
+ middleware.modify_request(_bootstrap_request())
+ )
+
+ monkeypatch.setattr(memory_module, "_current_thread_id", lambda: "t4")
+ assert "still has no `name`" in _system(
+ middleware.modify_request(_bootstrap_request())
+ )
+
+ for thread in ("t5", "t6", "t7"):
+ monkeypatch.setattr(memory_module, "_current_thread_id", lambda t=thread: t)
+ assert "" not in _system(
+ middleware.modify_request(_bootstrap_request())
+ )
+
+ monkeypatch.setattr(memory_module, "_current_thread_id", lambda: "t8")
+ assert "still has no `name`" in _system(
+ middleware.modify_request(_bootstrap_request())
+ )
+
+
+def test_bootstrap_without_thread_id_injects_without_bookkeeping(tmp_path, monkeypatch):
+ memories, middleware = _bootstrap_middleware(tmp_path, monkeypatch, thread_id=None)
+
+ system = _system(middleware.modify_request(_bootstrap_request()))
+ meta, _ = _user_profile_meta(memories)
+
+ assert "" in system
+ assert meta["evoscientist"]["sessions"] == 0
+ assert meta["evoscientist"]["intro_attempts"] == 0
+
+
+def test_bootstrap_write_failure_still_injects(tmp_path, monkeypatch):
+ _memories, middleware = _bootstrap_middleware(tmp_path, monkeypatch)
+ middleware.modify_request(_request()) # profile files exist now
+ monkeypatch.setattr(
+ memory_module.EvoMemoryMiddleware,
+ "_write_text",
+ lambda _self, _path, _content: False,
+ )
+
+ system = _system(middleware.modify_request(_bootstrap_request()))
+
+ assert "" in system
+
+
+def test_migrated_existing_user_gets_bootstrap_on_new_thread(tmp_path, monkeypatch):
+ memories = tmp_path / "memories"
+ profile_dir = memories / "profile"
+ profile_dir.mkdir(parents=True)
+ (profile_dir / "USER_PROFILE.md").write_text(
+ "# User profile\n\n## Preferences\n- Likes short reports\n", encoding="utf-8"
+ )
+ _, middleware = _bootstrap_middleware(tmp_path, monkeypatch)
+
+ system = _system(middleware.modify_request(_bootstrap_request()))
+
+ assert "" in system
+ assert "- Likes short reports" in system
+
+
+def test_bootstrap_skips_unparsable_frontmatter_without_writing(tmp_path, monkeypatch):
+ memories = tmp_path / "memories"
+ profile_dir = memories / "profile"
+ profile_dir.mkdir(parents=True)
+ original = "---\nname: [unclosed\n---\n# User profile\n"
+ (profile_dir / "USER_PROFILE.md").write_text(original, encoding="utf-8")
+ _, middleware = _bootstrap_middleware(tmp_path, monkeypatch)
+
+ system = _system(middleware.modify_request(_bootstrap_request()))
+
+ assert "" not in system
+ assert (profile_dir / "USER_PROFILE.md").read_text(encoding="utf-8") == original
+
+
+async def test_bootstrap_async_path_matches_sync(tmp_path, monkeypatch):
+ memories, middleware = _bootstrap_middleware(tmp_path, monkeypatch)
+
+ system = _system(await middleware.amodify_request(_bootstrap_request()))
+ meta, _ = _user_profile_meta(memories)
+
+ assert "" in system
+ assert system.index("") < system.index("")
+ assert meta["evoscientist"]["intro_attempts"] == 1
+
+ assert "" not in _system(
+ await middleware.amodify_request(_bootstrap_request(human_messages=2))
+ )
+
+
+def test_count_human_messages_and_thread_id_helpers():
+ from langchain_core.messages import AIMessage, HumanMessage
+
+ assert memory_module._count_human_messages({}) == 0
+ assert memory_module._count_human_messages({"messages": "nope"}) == 0
+ assert (
+ memory_module._count_human_messages(
+ {"messages": [HumanMessage(content="a"), AIMessage(content="b")]}
+ )
+ == 1
+ )
+ # Outside a runnable context there is no config, hence no thread id.
+ assert memory_module._current_thread_id() is None
+
+
+def test_count_human_messages_ignores_synthetic_summary_messages():
+ from langchain_core.messages import AIMessage, HumanMessage
+
+ summary = HumanMessage(
+ content="summary", additional_kwargs={"lc_source": "summarization"}
+ )
+ assert (
+ memory_module._count_human_messages(
+ {"messages": [summary, AIMessage(content="a")]}
+ )
+ == 0
+ )
+ assert (
+ memory_module._count_human_messages(
+ {"messages": [summary, HumanMessage(content="hi"), AIMessage(content="a")]}
+ )
+ == 1
+ )
+
+
+def test_bootstrap_ignores_post_summarization_synthetic_human_message(
+ tmp_path, monkeypatch
+):
+ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
+
+ _memories, middleware = _bootstrap_middleware(tmp_path, monkeypatch)
+ request = _request()
+ request.state = {
+ "messages": [
+ HumanMessage(
+ content="summary", additional_kwargs={"lc_source": "summarization"}
+ ),
+ AIMessage(content="ok"),
+ ToolMessage(content="x", tool_call_id="t"),
+ ]
+ }
+
+ system = _system(middleware.modify_request(request))
+
+ assert "" not in system
+
+
+# ---- profile bootstrap: assembly --------------------------------------------
+
+
+def _assembly_cfg(*, auto_mode: bool):
+ cfg = MagicMock()
+ cfg.enable_ask_user = False
+ cfg.auto_approve = True
+ cfg.auto_mode = auto_mode
+ cfg.auxiliary_model = ""
+ cfg.auxiliary_provider = ""
+ return cfg
+
+
+def _assemble(tmp_path, monkeypatch, *, auto_mode: bool, for_async_subagent=False):
+ from EvoScientist.EvoScientist import _get_default_middleware
+
+ workspace = tmp_path / "workspace"
+ workspace.mkdir(exist_ok=True)
+ monkeypatch.setattr(paths, "WORKSPACE_ROOT", workspace)
+ monkeypatch.setattr(paths, "MEMORIES_DIR", tmp_path / "memories")
+ with (
+ patch(
+ "EvoScientist.middleware.create_tool_selector_middleware", return_value=[]
+ ),
+ patch("EvoScientist.EvoScientist._ensure_chat_model") as mock_model,
+ patch("EvoScientist.EvoScientist._ensure_config") as mock_config,
+ ):
+ mock_config.return_value = _assembly_cfg(auto_mode=auto_mode)
+ mock_model.return_value = MagicMock(profile={"max_input_tokens": 200_000})
+ middleware = _get_default_middleware(for_async_subagent=for_async_subagent)
+ return next(
+ m for m in middleware if isinstance(m, memory_module.EvoMemoryMiddleware)
+ )
+
+
+def test_main_agent_enables_profile_bootstrap(tmp_path, monkeypatch):
+ instance = _assemble(tmp_path, monkeypatch, auto_mode=False)
+ assert instance._enable_profile_bootstrap is True
+
+
+def test_auto_mode_disables_profile_bootstrap(tmp_path, monkeypatch):
+ instance = _assemble(tmp_path, monkeypatch, auto_mode=True)
+ assert instance._enable_profile_bootstrap is False
+
+
+def test_async_subagent_disables_profile_bootstrap(tmp_path, monkeypatch):
+ instance = _assemble(
+ tmp_path, monkeypatch, auto_mode=False, for_async_subagent=True
+ )
+ assert instance._enable_profile_bootstrap is False
+
+
+def test_sync_subagent_site_does_not_pass_profile_bootstrap(tmp_path, monkeypatch):
+ from EvoScientist.EvoScientist import _inject_subagent_middleware
+
+ workspace = tmp_path / "workspace"
+ workspace.mkdir()
+ monkeypatch.setattr(paths, "WORKSPACE_ROOT", workspace)
+ monkeypatch.setattr(paths, "MEMORIES_DIR", tmp_path / "memories")
+ sub = {"name": "research"}
+ with (
+ patch("EvoScientist.EvoScientist._ensure_chat_model") as mock_model,
+ patch("EvoScientist.EvoScientist._ensure_config") as mock_config,
+ ):
+ mock_config.return_value = _assembly_cfg(auto_mode=False)
+ mock_model.return_value = MagicMock(profile={"max_input_tokens": 200_000})
+ _inject_subagent_middleware([sub], workspace_dir=workspace)
+
+ instance = next(
+ m for m in sub["middleware"] if isinstance(m, memory_module.EvoMemoryMiddleware)
+ )
+ assert instance._enable_profile_bootstrap is False
diff --git a/tests/test_stream_utils.py b/tests/test_stream_utils.py
index 2d3e01b..9ed5b83 100644
--- a/tests/test_stream_utils.py
+++ b/tests/test_stream_utils.py
@@ -189,6 +189,18 @@ class TestFormatToolCompact:
assert result == "Reading memory"
+ def test_profile_memory_headings_skip_user_profile_frontmatter(self):
+ from EvoScientist.stream import utils
+
+ utils._profile_memory_headings.cache_clear()
+ try:
+ headings = utils._profile_memory_headings()
+ finally:
+ utils._profile_memory_headings.cache_clear()
+
+ assert "# User profile" in headings
+ assert not any(heading.startswith("---") for heading in headings)
+
def test_project_memory_result_not_special(self):
result = format_tool_compact_with_result(
"write_file",