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",