feat(memory): first-contact profile bootstrap with USER_PROFILE frontmatter (#450)

This commit is contained in:
Xi Zhang
2026-09-05 13:46:39 +08:00
committed by GitHub
parent ee14afe3a3
commit 7dbb68d807
5 changed files with 1028 additions and 20 deletions
+2
View File
@@ -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).
+330 -15
View File
@@ -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"""
<profile_bootstrap>
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 <profile_memory> 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>
"""
PROFILE_BOOTSTRAP_RETRY = f"""
<profile_bootstrap>
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.
</profile_bootstrap>
"""
_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,
)
+10 -5
View File
@@ -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:
+674
View File
@@ -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 "<profile_bootstrap>" 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("<profile_bootstrap>")
assert block.strip().endswith("</profile_bootstrap>")
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 "<profile_bootstrap>" in system
assert "first exchange with this researcher" in system
assert system.index("</profile_memory>") < system.index("<profile_bootstrap>")
assert system.rstrip().endswith("</profile_bootstrap>")
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 "<profile_bootstrap>" 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 "<profile_bootstrap>" 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 "<profile_bootstrap>" 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 "<profile_bootstrap>" 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 "<profile_bootstrap>" 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 "<profile_bootstrap>" 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 "<profile_bootstrap>" 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 "<profile_bootstrap>" 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 "<profile_bootstrap>" 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 "<profile_bootstrap>" 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 "<profile_bootstrap>" in system
assert system.index("</profile_memory>") < system.index("<profile_bootstrap>")
assert meta["evoscientist"]["intro_attempts"] == 1
assert "<profile_bootstrap>" 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 "<profile_bootstrap>" 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
+12
View File
@@ -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",