feat(memory): first-contact profile bootstrap with USER_PROFILE frontmatter (#450)
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user