Files
EvoScientist-Multi/EvoScientist/middleware/memory.py
T

652 lines
25 KiB
Python

"""Memory middleware for EvoScientist.
The middleware owns the markdown files under ``/memories/profile/``: it creates
them when missing, migrates the old ``/memories/MEMORY.md`` file when present,
injects either profile contents or profile file pointers into model calls, and
points agents at observation memory under ``/memories/observations/``. Agents
still read and edit profile files through their normal ``/memories/...`` tools;
observation writes go through the structured ``record_observation`` tool.
"""
from __future__ import annotations
import asyncio
import logging
import re
from collections.abc import Awaitable, Callable
from pathlib import Path
from langchain.agents.middleware.types import (
AgentMiddleware,
ModelRequest,
ModelResponse,
)
from .. import paths as _paths
from ..memory import (
MemorySourceType,
ObservationRecordResult,
build_observation_index_context,
create_read_memory_tool,
create_record_observation_tool,
create_search_observations_tool,
)
from ..memory.project import resolve_project_id
from ..memory.scheduler import MemoryScheduler, ObservationLinkerContext
from .utils import append_to_system_message
logger = logging.getLogger(__name__)
DEFAULT_MAX_INLINE_PROFILE_CHARS = 24_000
_LEGACY_MEMORY_FILENAME = "MEMORY.md"
_LEGACY_IMPORT_HEADING = "Imported from legacy MEMORY.md"
PROFILE_MEMORY_INSTRUCTIONS = """
These profile notes live under `/memories/profile/`.
Every agent can read and update them with normal file tools.
Use these files for:
- `/memories/profile/SOUL.md`: how this copy should usually behave; voice and boundaries.
- `/memories/profile/USER_PROFILE.md`: facts and preferences about the user.
- `/memories/profile/RESEARCH_TASTE.md`: research interests, standards, methods that fit, and things to avoid.
- `/memories/profile/projects/{project_id}/PROJECT_PROFILE.md`: conventions, commands, and pitfalls for this workspace.
Read the relevant file before editing it. Add small bullets under existing
headings, skip duplicates, and leave out temporary task state.
Profile update scope:
- Review the profile context below and the latest trajectory for stable changes
to user preferences, research taste, collaboration style, or project
conventions.
- Do not infer profile facts from task content alone. Profile updates need
stable evidence about the user, their preferences, or this project.
- When a profile update is warranted, edit the relevant
`/memories/profile/...` file with a small deduplicated bullet under an
existing heading.
- When the turn only contains task progress, subagent findings, search results,
command output, or temporary run context, leave profile files unchanged.
"""
OBSERVATION_MEMORY_READ_INSTRUCTIONS = """
Observation memory lives under `/memories/observations/`:
- `/memories/observations/global/`: cross-project observations.
- `/memories/observations/projects/{project_id}/`: observations for this workspace.
Required memory preflight:
- For coding, debugging, research, planning, or evaluation tasks, complete this
preflight before inspecting workspace/task files, running commands, editing
files, delegating, using `code_interpreter`, or making a plan.
- First use the inlined observation index. If a listed summary clearly matches
the task, call `read_memory` with that observation ID.
- Otherwise, call `search_observations` with a few distinctive words or short
phrases that describe the issue, constraint, procedure, or prior result to
find. If one query misses, try 1-3 focused variants. Use `mode=regex` only
when exact grep-like matching is required. If a result looks promising but
the snippet is not enough to act on confidently, call `read_memory` with its
observation ID.
- After this preflight, use direct tools or `code_interpreter` to do or batch
the actual workspace work as appropriate.
- Mention the result briefly before continuing: observation IDs used, or that
no relevant observation was found. Keep this preflight short.
"""
OBSERVATION_MEMORY_WRITE_INSTRUCTIONS = """
Call `record_observation` only for durable, non-obvious, evidence-backed
information that is not already in memory and is likely to change future behavior:
recurring constraints, important decisions, failed approaches future agents might
repeat, verified outcomes, or tool/workflow workarounds.
Provide a one-line `summary` that is specific enough for future agents to decide
whether to read the full observation.
Distill reusable insight rather than saving raw task output or a transcript of
what happened.
Use procedural/global for general tool or platform behavior that can recur
outside this workspace; use project scope only for workspace-specific facts,
commands, resources, evaluation setup, or configuration. Do not hand-write
observation files.
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_TEMPLATES: dict[str, str] = {
"/profile/SOUL.md": """# EvoScientist soul
Default behavior for this copy of EvoScientist.
## Operating principles
## Voice
## Lines not to cross
""",
"/profile/USER_PROFILE.md": """# User profile
Things worth remembering about the person using EvoScientist.
## Stable facts
## Preferences
## Collaboration style
## Constraints
""",
"/profile/RESEARCH_TASTE.md": """# Research taste
Research taste to keep in mind: interests, standards, methods that tend to fit, and things to avoid.
## Interests
## Standards
## Methods that fit
## Things to avoid
""",
"/profile/projects/{project_id}/PROJECT_PROFILE.md": """# Project profile
Notes about this workspace: conventions, commands, tests, and traps.
## Workspace conventions
## Commands that work
## Evaluation and testing
## Known traps
""",
}
def _profile_specs(project_id: str) -> list[tuple[str, str]]:
"""Return the profile files owned by this middleware and their templates."""
return [
(path.format(project_id=project_id), template)
for path, template in PROFILE_TEMPLATES.items()
]
def _agent_path(memory_path: str) -> str:
"""Translate a memory-relative path to the virtual path agents see."""
return f"/memories{memory_path}"
def _legacy_sections(content: str) -> tuple[str, list[tuple[str, str]]]:
"""Split the old ``MEMORY.md`` format into preface and top-level sections."""
pattern = re.compile(
r"^## (?P<heading>.+?)\n(?P<body>.*?)(?=^## |\Z)",
flags=re.MULTILINE | re.DOTALL,
)
sections = [
(match.group("heading").strip(), match.group("body").strip())
for match in pattern.finditer(content)
]
first = pattern.search(content)
preface = content[: first.start()].strip() if first else content.strip()
return preface, sections
def _is_legacy_placeholder_line(line: str) -> bool:
"""Return whether a legacy line is only default-template filler."""
stripped = line.strip()
if stripped in {"", "- (none yet)", "- (none)", "(No experiments yet)", "(none)"}:
return True
return bool(re.fullmatch(r"- \*\*[^*]+\*\*:\s*\(unknown\)", stripped))
def _clean_legacy_body(body: str) -> str:
"""Drop old template placeholders while keeping real legacy notes."""
lines = [
line.rstrip()
for line in body.strip().splitlines()
if not _is_legacy_placeholder_line(line)
]
return "\n".join(lines).strip()
def _clean_legacy_preface(preface: str) -> str:
"""Remove the old root heading from pre-section legacy text."""
lines = [
line.rstrip()
for line in preface.strip().splitlines()
if line.strip() != "# EvoScientist Memory"
]
return "\n".join(lines).strip()
def _append_imported_section(content: str, body: str) -> str:
"""Append migrated legacy text under a clear, inspectable heading."""
return content.rstrip() + f"\n\n## {_LEGACY_IMPORT_HEADING}\n\n{body.strip()}\n"
class EvoMemoryMiddleware(AgentMiddleware):
"""Middleware that maintains the profile memory files used by EvoScientist.
The middleware bootstraps missing files, migrates legacy memory, and adds
profile context to model requests.
"""
def __init__(
self,
*,
memory_dir: str | Path,
workspace_dir: str | Path | None = None,
max_inline_profile_chars: int = DEFAULT_MAX_INLINE_PROFILE_CHARS,
source_type: MemorySourceType = MemorySourceType.TURN,
source_agent: str = "EvoScientist",
enable_profile_memory: bool = True,
enable_observation_memory: bool = True,
enable_observation_tool: bool = True,
memory_scheduler: MemoryScheduler | None = None,
) -> 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_observation_memory = enable_observation_memory
self._memory_scheduler = memory_scheduler
self._profile_specs = _profile_specs(self._project_id)
pointer_lines = ["Profile files are available at:"]
pointer_lines.extend(
f"- {_agent_path(path)}" for path, _ in self._profile_specs
)
self._profile_pointer_context = "\n".join(pointer_lines)
self._max_inline_profile_chars = max_inline_profile_chars
self._enable_observation_tool = (
enable_observation_memory and enable_observation_tool
)
self.tools = []
if enable_observation_memory:
self.tools.append(
create_search_observations_tool(
memory_dir=self._memory_dir,
project_id=self._project_id,
)
)
self.tools.append(
create_read_memory_tool(
memory_dir=self._memory_dir,
project_id=self._project_id,
)
)
if self._enable_observation_tool:
self.tools.append(
create_record_observation_tool(
memory_dir=self._memory_dir,
project_id=self._project_id,
source_type=source_type,
source_agent=source_agent,
on_observation_recorded=self._record_observation_created,
)
)
self._observation_index_context = ""
if not enable_observation_memory:
return
self._refresh_observation_index_context()
@property
def project_id(self) -> str:
"""Stable project id used for this middleware's project memory paths."""
return self._project_id
def _record_observation_created(self, result: ObservationRecordResult) -> None:
if self._memory_scheduler is None:
return
project_id = str(result.get("project_id") or self._project_id)
self._memory_scheduler.record_observation_created(
ObservationLinkerContext(
memory_dir=self._memory_dir,
workspace_dir=self._workspace_dir,
project_id=project_id,
observation_ids=(result["observation_id"],),
)
)
def _runtime_memory_dir(self) -> str | None:
try:
from langgraph.config import get_config
cfg = get_config()
except Exception:
return None
configurable = cfg.get("configurable") if isinstance(cfg, dict) else None
if not isinstance(configurable, dict):
return None
value = configurable.get("ai4sci_memory_dir")
return value if isinstance(value, str) and value else None
def _file_path(self, memory_path: str, memory_dir: str | Path | None = None) -> Path:
"""Resolve a memory-relative path against the memory directory."""
root = Path(memory_dir) if memory_dir is not None else self._memory_dir
return root / memory_path.lstrip("/")
def _read_text(self, path: Path) -> str | None:
"""Read UTF-8 text, returning None only when the file is absent."""
try:
return path.read_text(encoding="utf-8")
except FileNotFoundError:
return None
except (OSError, UnicodeDecodeError) as e:
logger.warning("Failed to read profile memory %s: %s", path, e)
raise
def _write_text(self, path: Path, content: str) -> bool:
"""Write UTF-8 text, creating parent directories as needed."""
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
except OSError as e:
logger.warning("Failed to write profile memory %s: %s", path, e)
return False
return True
def _delete_legacy_memory(self, legacy_path: Path) -> bool:
"""Remove the old memory file after it has no content left to preserve."""
try:
legacy_path.unlink()
except FileNotFoundError:
pass
except OSError as e:
logger.warning("Failed to delete legacy memory %s: %s", legacy_path, e)
return False
return True
def _ensure_observation_dirs(self, memory_dir: str | Path | None = None) -> None:
"""Create non-project observation directories agents are prompted to search."""
try:
self._file_path("/observations/global", memory_dir).mkdir(parents=True, exist_ok=True)
except OSError as e:
logger.warning("Failed to create observation memory dir: %s", e)
def _ensure_profile_files(self, memory_dir: str | Path | None = None) -> list[tuple[str, str]]:
"""Create the expected profile files if needed and return their contents."""
records = []
for memory_path, template in self._profile_specs:
path = self._file_path(memory_path, memory_dir)
content = self._read_text(path)
if content is None:
if not self._write_text(path, template):
raise OSError(f"Failed to bootstrap profile file: {path}")
content = template
records.append((memory_path, content))
return records
def _migrate_legacy_memory(self, memory_dir: str | Path | None = None) -> bool:
"""Import recognized sections from legacy ``MEMORY.md`` into profiles.
The legacy file is removed only after real content is copied or the file
is found to contain only old template placeholders.
"""
legacy_path = (Path(memory_dir) if memory_dir is not None else self._memory_dir) / _LEGACY_MEMORY_FILENAME
legacy = self._read_text(legacy_path)
if legacy is None:
return True
if not legacy.strip():
return self._delete_legacy_memory(legacy_path)
user_profile_path = "/profile/USER_PROFILE.md"
research_taste_path = "/profile/RESEARCH_TASTE.md"
imports: dict[str, list[str]] = {
user_profile_path: [],
research_taste_path: [],
}
recognized_paths = {
"User Profile": user_profile_path,
"Research Preferences": research_taste_path,
"Experiment History": user_profile_path,
"Learned Preferences": user_profile_path,
}
preface, legacy_sections = _legacy_sections(legacy)
preface_body = _clean_legacy_preface(preface)
if preface_body:
imports[user_profile_path].append(f"### Notes\n{preface_body}")
for heading, body in legacy_sections:
cleaned = _clean_legacy_body(body)
if not cleaned:
continue
target_path = recognized_paths.get(heading, user_profile_path)
imports.setdefault(target_path, []).append(f"### {heading}\n{cleaned}")
imported_any = False
for memory_path, bodies in imports.items():
if not bodies:
continue
path = self._file_path(memory_path, memory_dir)
content = self._read_text(path)
if content is None:
logger.warning(
"Skipping legacy memory migration for missing profile %s", path
)
return False
body = "\n\n".join(bodies)
if not self._write_text(path, _append_imported_section(content, body)):
return False
imported_any = True
if not imported_any:
logger.debug("Legacy MEMORY.md contained no real content to migrate")
return self._delete_legacy_memory(legacy_path)
def _read_bootstrapped_profile_records(self, memory_dir=None) -> list[tuple[str, str]]:
records = self._ensure_profile_files(memory_dir)
if self._migrate_legacy_memory(memory_dir):
records = [
(memory_path, self._read_text(self._file_path(memory_path, memory_dir)) or "")
for memory_path, _ in records
]
return records
def _read_profile_records(self, memory_dir=None) -> list[tuple[str, str]]:
"""Load all profile files after bootstrapping and legacy migration."""
if not self._enable_observation_memory:
return self._read_bootstrapped_profile_records(memory_dir)
self._ensure_observation_dirs(memory_dir)
return self._read_bootstrapped_profile_records(memory_dir)
def _profile_context_from_records(self, records: list[tuple[str, str]]) -> str:
"""Inline profile contents unless they exceed the prompt budget."""
full = "\n\n".join(
f"File: {_agent_path(path)}\n\n{content.strip()}"
for path, content in records
if content.strip()
).strip()
if len(full) <= self._max_inline_profile_chars:
return full
return self._profile_pointer_context
def _read_profile_memory(self, memory_dir=None) -> str:
"""Return profile context, falling back to file pointers."""
try:
records = self._read_profile_records(memory_dir)
return (
self._profile_context_from_records(records)
or self._profile_pointer_context
)
except Exception as e:
logger.debug("Failed to read profile memory: %s", e)
return self._profile_pointer_context
def _refresh_observation_index_context(self, memory_dir=None) -> str:
"""Refresh the prompt observation index from current memory files."""
if not self._enable_observation_memory:
return ""
try:
self._ensure_observation_dirs(memory_dir)
context = build_observation_index_context(
memory_dir=(Path(memory_dir) if memory_dir is not None else self._memory_dir),
project_id=self._project_id,
)
except OSError as e:
logger.warning("Failed to refresh observation memory index: %s", e)
return self._observation_index_context
except Exception as e:
logger.debug("Failed to refresh observation memory index: %s", e)
return self._observation_index_context
self._observation_index_context = context
return context
def _observation_memory_instructions(self) -> str:
if not self._enable_observation_memory:
return ""
instructions = OBSERVATION_MEMORY_READ_INSTRUCTIONS.format(
project_id=self._project_id
)
if not self._enable_observation_tool:
return instructions
return instructions + OBSERVATION_MEMORY_WRITE_INSTRUCTIONS
def _memory_instructions_context(self) -> str:
"""Return static memory instructions for enabled memory features."""
instructions = []
if self._enable_profile_memory:
instructions.append(
PROFILE_MEMORY_INSTRUCTIONS.format(project_id=self._project_id)
)
if observation_instructions := self._observation_memory_instructions():
instructions.append(observation_instructions)
if not instructions:
return ""
return "\n".join(
[
"<memory_instructions>",
"\n\n".join(part.strip() for part in instructions if part.strip()),
"</memory_instructions>",
]
)
def _profile_memory_context(self, profile_content: str) -> str:
"""Return profile memory context for prompt injection."""
if not self._enable_profile_memory:
return ""
return "\n".join(
[
"<profile_memory>",
profile_content,
"</profile_memory>",
]
)
def _memory_context_for_request(
self,
*,
observation_index_context: str,
profile_content: str,
) -> str:
"""Build request memory context ordered from static to dynamic."""
return "\n\n".join(
part
for part in (
self._memory_instructions_context(),
observation_index_context,
self._profile_memory_context(profile_content),
)
if part
)
def _inject_memory_context(
self,
request: ModelRequest,
*,
observation_index_context: str,
profile_content: str,
) -> ModelRequest:
"""Append memory context and editing guidance to the system prompt."""
if not self._enable_profile_memory and not self._enable_observation_memory:
return request
injection = self._memory_context_for_request(
observation_index_context=observation_index_context,
profile_content=profile_content,
)
new_system = append_to_system_message(request.system_message, injection)
return request.override(system_message=new_system)
def _profile_context_for_request(self) -> str:
if not self._enable_profile_memory:
return ""
return self._read_profile_memory()
def modify_request(self, request: ModelRequest) -> ModelRequest:
"""Apply memory injection for synchronous model calls."""
return self._inject_memory_context(
request,
observation_index_context=self._refresh_observation_index_context(),
profile_content=self._profile_context_for_request(),
)
async def amodify_request(self, request: ModelRequest) -> ModelRequest:
"""Apply memory injection for asynchronous model calls."""
memory_dir = self._runtime_memory_dir()
observation_index_context = ""
profile_context = ""
if self._enable_observation_memory and self._enable_profile_memory:
observation_index_context, profile_context = await asyncio.gather(
asyncio.to_thread(self._refresh_observation_index_context, memory_dir),
asyncio.to_thread(self._read_profile_memory, memory_dir),
)
elif self._enable_observation_memory:
observation_index_context = await asyncio.to_thread(
self._refresh_observation_index_context, memory_dir
)
elif self._enable_profile_memory:
profile_context = await asyncio.to_thread(self._read_profile_memory, memory_dir)
return self._inject_memory_context(
request,
observation_index_context=observation_index_context,
profile_content=profile_context,
)
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
"""Middleware hook for injecting context before the sync model handler."""
return handler(self.modify_request(request))
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelResponse:
"""Middleware hook for injecting context before the async model handler."""
return await handler(await self.amodify_request(request))
def create_memory_middleware(
memory_dir: str | None = None,
workspace_dir: str | Path | None = None,
max_inline_profile_chars: int = DEFAULT_MAX_INLINE_PROFILE_CHARS,
source_type: MemorySourceType = MemorySourceType.TURN,
source_agent: str = "EvoScientist",
enable_profile_memory: bool = True,
enable_observation_memory: bool = True,
enable_observation_tool: bool = True,
memory_scheduler: MemoryScheduler | None = None,
) -> EvoMemoryMiddleware:
"""Build profile-memory middleware, defaulting to the shared memories directory."""
if memory_dir is None:
memory_dir = str(_paths.MEMORIES_DIR)
return EvoMemoryMiddleware(
memory_dir=memory_dir,
workspace_dir=workspace_dir,
max_inline_profile_chars=max_inline_profile_chars,
source_type=source_type,
source_agent=source_agent,
enable_profile_memory=enable_profile_memory,
enable_observation_memory=enable_observation_memory,
enable_observation_tool=enable_observation_tool,
memory_scheduler=memory_scheduler,
)