Files
EvoScientist-Multi/tests/test_observation_memory.py
T
jfilipiuk 99075345ab fix: bound cache-eviction race test by deadline to fit Windows CI timeout (#436)
* fix: bound cache-eviction race test by deadline to fit Windows CI timeout

* test: start cache-eviction race workers from a shared barrier and assert progress

---------

Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
2026-08-25 11:10:53 +00:00

3444 lines
119 KiB
Python

from __future__ import annotations
import json
import re
import threading
from collections.abc import Sequence
from datetime import datetime
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
import pytest
import yaml
from blockbuster import BlockBuster
from langchain.agents.middleware.types import AgentState
from langchain.tools import ToolRuntime
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_core.runnables import RunnableConfig
from langchain_core.tools import BaseTool
from langgraph.runtime import ExecutionInfo, Runtime
from pydantic import BaseModel
from EvoScientist.config import EvoScientistConfig, MemoryObservationWriter
from EvoScientist.gateway import background_runs
from EvoScientist.memory import (
launch as memory_launch,
)
from EvoScientist.memory import (
scheduler as memory_scheduler,
)
from EvoScientist.memory import (
source_context,
worker_activity,
)
from EvoScientist.memory.agents import memory_worker, observation_linker
from EvoScientist.memory.observations import (
MemoryScope,
MemorySourceType,
MemoryType,
ObservationSearchMode,
create_link_observations_tool,
create_read_memory_tool,
create_search_observations_tool,
link_observation_files,
list_observation_documents,
read_observation_document,
read_observation_file,
read_observation_id_from_path,
record_observation_file,
search_observation_files,
)
from EvoScientist.memory.types import ObservationRelation
from EvoScientist.middleware import memory_lifecycle
def _read_memory_document(path) -> tuple[dict[str, Any], str]:
text = path.read_text(encoding="utf-8")
assert text.startswith("---\n")
frontmatter, body = text.removeprefix("---\n").split("\n---\n", 1)
metadata = yaml.safe_load(frontmatter)
assert isinstance(metadata, dict)
return metadata, body
def _stable_created_at(metadata: dict[str, Any]) -> dict[str, Any]:
created_at = metadata.get("created_at")
assert isinstance(created_at, str)
datetime.strptime(created_at, "%Y-%m-%dT%H:%M:%SZ")
return {**metadata, "created_at": "<created_at>"}
def _markdown_sections(body: str) -> dict[str, str]:
matches = list(re.finditer(r"^## (?P<title>.+)$", body, flags=re.MULTILINE))
sections: dict[str, str] = {}
for index, match in enumerate(matches):
start = match.end()
end = matches[index + 1].start() if index + 1 < len(matches) else len(body)
sections[match.group("title")] = body[start:end].strip()
return sections
def _execution_info(thread_id: str | None = None) -> ExecutionInfo:
return ExecutionInfo(
checkpoint_id="checkpoint-1",
checkpoint_ns="",
task_id="task-1",
thread_id=thread_id,
)
def _tool_runtime(
tool: Any,
*,
config: RunnableConfig | None = None,
thread_id: str | None = None,
tool_call_id: str | None = None,
) -> ToolRuntime:
runtime_config: RunnableConfig = config if config is not None else {}
return ToolRuntime(
state={},
context=None,
config=runtime_config,
stream_writer=lambda _chunk: None,
tool_call_id=tool_call_id,
store=None,
tools=[tool],
execution_info=_execution_info(thread_id),
server_info=None,
)
def _runtime(thread_id: str | None = None) -> Runtime[None]:
return Runtime(execution_info=_execution_info(thread_id))
def _memory_source_context(
*,
memory_dir,
workspace_dir,
source_type: MemorySourceType = MemorySourceType.TURN,
project_id: str = "P-project",
source_agent: str = "EvoScientist",
session_id: str = "thread-1",
trajectory: list[source_context.CompactMessage] | None = None,
) -> source_context.MemorySourceContext:
context_trajectory = trajectory or [{"role": "human", "content": "hi"}]
return source_context.MemorySourceContext(
source_type=source_type,
memory_dir=memory_dir,
workspace_dir=workspace_dir,
project_id=project_id,
source_agent=source_agent,
session_id=session_id,
trajectory=context_trajectory,
trajectory_digest=source_context._trajectory_digest(context_trajectory),
)
def _memory_worker_run(
*,
thread_id: str = "worker-thread",
run_id: str = "run-1",
workspace_dir: str = "/tmp/ws",
project_id: str = "P-project",
source_agent: str = "EvoScientist",
source_session_id: str = "thread-1",
trajectory_digest: str = "digest-1",
) -> background_runs.BackgroundRun:
return background_runs.BackgroundRun(
name="EvoMemory worker",
url="http://x",
graph_id=memory_launch.TURN_MEMORY_WORKER_GRAPH_ID,
thread_id=thread_id,
run_id=run_id,
assistant_id=memory_launch.TURN_MEMORY_WORKER_GRAPH_ID,
metadata={
"workspace_dir": workspace_dir,
"project_id": project_id,
"source_agent": source_agent,
"source_session_id": source_session_id,
"trajectory_digest": trajectory_digest,
},
)
def _observation_linker_run(
*,
thread_id: str = "linker-thread",
run_id: str = "linker-run",
) -> background_runs.BackgroundRun:
return background_runs.BackgroundRun(
name="EvoMemory observation linker",
url="http://x",
graph_id=memory_launch.OBSERVATION_LINKER_GRAPH_ID,
thread_id=thread_id,
run_id=run_id,
assistant_id=memory_launch.OBSERVATION_LINKER_GRAPH_ID,
metadata={},
)
def _linker_context(
*,
memory_dir,
workspace_dir,
observation_ids: tuple[str, ...],
project_id: str = "P-project",
) -> memory_scheduler.ObservationLinkerContext:
return memory_scheduler.ObservationLinkerContext(
memory_dir=memory_dir,
workspace_dir=workspace_dir,
project_id=project_id,
observation_ids=observation_ids,
)
def _mark_worker_started(
memory_dir,
*,
thread_id: str = "worker-thread",
run_id: str = "run-1",
before_outputs: worker_activity.MemoryOutputSnapshot | None = None,
) -> None:
worker_activity.mark_memory_worker_started(
thread_id=thread_id,
run_id=run_id,
memory_dir=memory_dir,
before_outputs=(
before_outputs
if before_outputs is not None
else worker_activity.snapshot_memory_outputs(memory_dir)
),
)
def _record_test_observation(
memory_dir,
*,
summary: str = "Durable test observation.",
observation: str = "A reusable test observation.",
scope: MemoryScope = MemoryScope.GLOBAL,
) -> dict[str, Any]:
return record_observation_file(
memory_dir=memory_dir,
project_id="P-project",
memory_type=MemoryType.PROCEDURAL,
summary=summary,
observation=observation,
why_it_matters=f"Future agents can use this test memory: {summary}",
scope=scope,
source_type=MemorySourceType.TURN,
source_session_id="thread-1",
source_agent="EvoScientist",
)
def _memory_relative_path(record: dict[str, Any]) -> str:
return record["path"].removeprefix("/memories/")
def _record_observation_payload(
tool: Any,
*,
runtime: ToolRuntime,
memory_type: MemoryType,
summary: str,
observation: str,
why_it_matters: str,
scope: MemoryScope,
) -> dict[str, Any]:
payload = tool.run(
{
"memory_type": memory_type,
"summary": summary,
"observation": observation,
"why_it_matters": why_it_matters,
"scope": scope,
"runtime": runtime,
}
)
return json.loads(payload)
def _tool_by_name(tools: Sequence[BaseTool], name: str) -> BaseTool:
matches = [tool for tool in tools if tool.name == name]
assert len(matches) == 1
return matches[0]
def _fast_watcher_config(
*, max_poll_failures: int = 3
) -> background_runs.BackgroundRunWatcherConfig:
return background_runs.BackgroundRunWatcherConfig(
poll_interval_seconds=0,
max_poll_failures=max_poll_failures,
)
@pytest.fixture(autouse=True)
def _reset_memory_activity():
worker_activity.reset_memory_worker_status_for_tests()
yield
worker_activity.reset_memory_worker_status_for_tests()
def test_record_observation_file_writes_contract_and_dedupes(tmp_path):
memories = tmp_path / "memories"
summary = "Focused pytest catches local regressions before broader runs."
observation = "Run pytest with the focused file before the full suite."
why_it_matters = "This catches local regressions faster."
evidence = "Command: uv run pytest tests/test_observation_memory.py"
first = record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.PROCEDURAL,
summary=summary,
observation=observation,
why_it_matters=why_it_matters,
evidence=evidence,
scope=MemoryScope.PROJECT,
source_type=MemorySourceType.SUBAGENT,
source_session_id="thread-1",
source_agent="code-agent",
)
second = record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.PROCEDURAL,
summary=summary,
observation=observation,
why_it_matters=why_it_matters,
evidence=evidence,
scope=MemoryScope.PROJECT,
source_type=MemorySourceType.SUBAGENT,
source_session_id="thread-1",
source_agent="code-agent",
)
path = memories / first["path"].removeprefix("/memories/")
metadata, body = _read_memory_document(path)
assert first["created"] is True
assert second == {**first, "created": False}
assert first["path"] == (
f"/memories/observations/projects/P-project/{first['observation_id']}.md"
)
assert _stable_created_at(metadata) == {
"id": first["observation_id"],
"created_at": "<created_at>",
"summary": summary,
"memory_type": "procedural",
"scope": "project",
"project_id": "P-project",
"source": {
"type": "subagent",
"agent": "code-agent",
"session_id": "thread-1",
},
}
assert _markdown_sections(body) == {
"Observation": observation,
"Why It Matters": why_it_matters,
"Evidence": evidence,
}
def test_link_observation_files_writes_frontmatter_and_dedupes(tmp_path):
memories = tmp_path / "memories"
first = record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.PROCEDURAL,
summary="Graph gateway launches background runs.",
observation="Use the graph gateway background run service for workers.",
why_it_matters="Future launchers avoid duplicating SDK plumbing.",
scope=MemoryScope.PROJECT,
source_type=MemorySourceType.TURN,
source_session_id="thread-1",
source_agent="EvoScientist",
)
second = record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.PROCEDURAL,
summary="Memory linkers should update metadata.",
observation="Observation links belong in frontmatter metadata.",
why_it_matters="Future indexing can consume links without parsing prose.",
scope=MemoryScope.PROJECT,
source_type=MemorySourceType.TURN,
source_session_id="thread-1",
source_agent="EvoScientist",
)
first_path = memories / first["path"].removeprefix("/memories/")
second_path = memories / second["path"].removeprefix("/memories/")
_first_metadata, first_body_before = _read_memory_document(first_path)
_second_metadata, second_body_before = _read_memory_document(second_path)
result = link_observation_files(
memory_dir=memories,
project_id="P-project",
source_observation_id=first["observation_id"],
target_observation_id=second["observation_id"],
relation=ObservationRelation.COMPLEMENTS,
reason="Both observations describe the durable background-memory flow.",
)
duplicate = link_observation_files(
memory_dir=memories,
project_id="P-project",
source_observation_id=first["observation_id"],
target_observation_id=second["observation_id"],
relation=ObservationRelation.COMPLEMENTS,
reason="Both observations describe the durable background-memory flow.",
)
first_metadata, first_body_after = _read_memory_document(first_path)
second_metadata, second_body_after = _read_memory_document(second_path)
assert result == {
"linked": True,
"source_observation_id": first["observation_id"],
"target_observation_id": second["observation_id"],
"relation": "complements",
"updated_observation_ids": [
first["observation_id"],
second["observation_id"],
],
"missing_observation_ids": [],
}
assert duplicate == {
**result,
"linked": False,
"updated_observation_ids": [],
}
assert first_body_after == first_body_before
assert second_body_after == second_body_before
first_links = first_metadata["related_observations"]
second_links = second_metadata["related_observations"]
assert len(first_links) == 1
assert len(second_links) == 1
assert first_links[0] == {
"id": second["observation_id"],
"relation": "complements",
"reason": "Both observations describe the durable background-memory flow.",
"linked_at": first_links[0]["linked_at"],
}
assert second_links[0] == {
"id": first["observation_id"],
"relation": "complements",
"reason": "Both observations describe the durable background-memory flow.",
"linked_at": first_links[0]["linked_at"],
}
datetime.strptime(first_links[0]["linked_at"], "%Y-%m-%dT%H:%M:%SZ")
def test_link_observation_files_serializes_concurrent_frontmatter_updates(tmp_path):
memories = tmp_path / "memories"
source = record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.SEMANTIC,
summary="Source observation for concurrent links.",
observation="Several linker workers may update this observation.",
why_it_matters="Concurrent linkers must not lose frontmatter updates.",
scope=MemoryScope.PROJECT,
source_type=MemorySourceType.TURN,
source_session_id="thread-1",
source_agent="EvoScientist",
)
targets = [
record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.SEMANTIC,
summary=f"Target observation {index}.",
observation=f"Concurrent target observation {index}.",
why_it_matters=f"Target {index} should remain linked.",
scope=MemoryScope.PROJECT,
source_type=MemorySourceType.TURN,
source_session_id="thread-1",
source_agent="EvoScientist",
)
for index in range(12)
]
barrier = threading.Barrier(len(targets))
errors: list[Exception] = []
def link_target(index: int, target: dict[str, Any]) -> None:
try:
barrier.wait(timeout=5)
link_observation_files(
memory_dir=memories,
project_id="P-project",
source_observation_id=source["observation_id"],
target_observation_id=target["observation_id"],
relation=ObservationRelation.COMPLEMENTS,
reason=f"Target {index} is relevant to the shared source.",
bidirectional=False,
)
except Exception as exc:
errors.append(exc)
threads = [
threading.Thread(target=link_target, args=(index, target))
for index, target in enumerate(targets)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert errors == []
source_path = memories / source["path"].removeprefix("/memories/")
source_metadata, _source_body = _read_memory_document(source_path)
linked_ids = {entry["id"] for entry in source_metadata["related_observations"]}
assert linked_ids == {target["observation_id"] for target in targets}
def test_read_and_search_surface_related_observations(tmp_path):
memories = tmp_path / "memories"
source = record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.PROCEDURAL,
summary="Gateway memory workers preserve launch metadata.",
observation="Use the gateway service when launching memory workers.",
why_it_matters="Future launchers should reuse the same async-run plumbing.",
scope=MemoryScope.PROJECT,
source_type=MemorySourceType.TURN,
source_session_id="thread-1",
source_agent="EvoScientist",
)
target = record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.SEMANTIC,
summary="Observation links should be visible during retrieval.",
observation="Related observations need to surface in memory tool results.",
why_it_matters="Future agents can use existing links without parsing YAML.",
scope=MemoryScope.PROJECT,
source_type=MemorySourceType.TURN,
source_session_id="thread-1",
source_agent="EvoScientist",
)
link_observation_files(
memory_dir=memories,
project_id="P-project",
source_observation_id=source["observation_id"],
target_observation_id=target["observation_id"],
relation=ObservationRelation.COMPLEMENTS,
reason="Launch metadata and retrieval visibility describe the same memory pipeline.",
)
read = read_observation_file(
memory_dir=memories,
project_id="P-project",
observation_id=source["observation_id"],
)
hits = search_observation_files(
memory_dir=memories,
project_id="P-project",
query="gateway memory workers launch metadata",
)
assert read is not None
assert read["related_observations"][0]["observation_id"] == target["observation_id"]
assert (
read["related_observations"][0]["relation"] == ObservationRelation.COMPLEMENTS
)
assert hits[0]["observation_id"] == source["observation_id"]
assert (
hits[0]["related_observations"][0]["observation_id"] == target["observation_id"]
)
read_tool = create_read_memory_tool(memory_dir=memories, project_id="P-project")
read_payload = json.loads(
read_tool.run({"observation_id": source["observation_id"]})
)
search_tool = create_search_observations_tool(
memory_dir=memories,
project_id="P-project",
)
search_payload = json.loads(
search_tool.run({"query": "gateway memory workers launch metadata"})
)
assert (
read_payload["related_observations"][0]["observation_id"]
== target["observation_id"]
)
assert (
search_payload["results"][0]["related_observations"][0]["observation_id"]
== target["observation_id"]
)
def test_read_and_search_resolve_related_observations_from_other_projects(tmp_path):
memories = tmp_path / "memories"
source = record_observation_file(
memory_dir=memories,
project_id="P-current",
memory_type=MemoryType.PROCEDURAL,
summary="Global linker practice applies across projects.",
observation="Global observations may link to project-specific follow-ups.",
why_it_matters="Related observations should remain visible from other projects.",
scope=MemoryScope.GLOBAL,
source_type=MemorySourceType.TURN,
source_session_id="thread-1",
source_agent="EvoScientist",
)
target = record_observation_file(
memory_dir=memories,
project_id="P-other",
memory_type=MemoryType.SEMANTIC,
summary="Other project follow-up explains the linker practice.",
observation="A separate project can hold the concrete follow-up observation.",
why_it_matters="Global observations should surface explicitly linked project memories.",
scope=MemoryScope.PROJECT,
source_type=MemorySourceType.TURN,
source_session_id="thread-2",
source_agent="EvoScientist",
)
link_observation_files(
memory_dir=memories,
project_id="P-other",
source_observation_id=source["observation_id"],
target_observation_id=target["observation_id"],
relation=ObservationRelation.COMPLEMENTS,
reason="The other project gives a concrete follow-up for the global practice.",
bidirectional=False,
)
read = read_observation_file(
memory_dir=memories,
project_id="P-current",
observation_id=source["observation_id"],
)
hits = search_observation_files(
memory_dir=memories,
project_id="P-current",
query="global linker practice",
)
assert read is not None
assert read["related_observations"][0]["observation_id"] == target["observation_id"]
assert (
hits[0]["related_observations"][0]["observation_id"] == target["observation_id"]
)
def test_malformed_observation_frontmatter_is_skipped(tmp_path):
memories = tmp_path / "memories"
valid = record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.SEMANTIC,
summary="Valid observations remain searchable.",
observation="A malformed neighboring observation file must not break search.",
why_it_matters="One bad memory file should not hide the rest of memory.",
scope=MemoryScope.PROJECT,
source_type=MemorySourceType.TURN,
source_session_id="thread-1",
source_agent="EvoScientist",
)
global_dir = memories / "observations" / "global"
global_dir.mkdir(parents=True, exist_ok=True)
missing_id = global_dir / "missing-id.md"
missing_id.write_text(
"---\n"
"summary: Missing id should skip this file\n"
"memory_type: semantic\n"
"scope: global\n"
"---\n"
"Body\n",
encoding="utf-8",
)
bad_link = global_dir / "bad-link.md"
bad_link.write_text(
"---\n"
'id: "O-bad-link"\n'
'summary: "Invalid relation entries should be ignored"\n'
"memory_type: semantic\n"
"scope: global\n"
"related_observations:\n"
' - id: "O-target"\n'
' relation: "unbounded"\n'
' reason: "not a supported relation"\n'
' linked_at: "2026-06-25T00:00:00Z"\n'
"---\n"
"Body\n",
encoding="utf-8",
)
assert read_observation_id_from_path(missing_id) is None
hits = search_observation_files(
memory_dir=memories,
project_id="P-project",
query="malformed neighboring observation",
)
assert [hit["observation_id"] for hit in hits] == [valid["observation_id"]]
assert worker_activity.snapshot_observation_relations(memories) == frozenset()
def test_legacy_observation_source_without_session_id_still_reads(tmp_path):
memories = tmp_path / "memories"
global_dir = memories / "observations" / "global"
global_dir.mkdir(parents=True)
legacy = global_dir / "O-legacy.md"
legacy.write_text(
"---\n"
"id: O-legacy\n"
"created_at: 2026-01-01T00:00:00Z\n"
"summary: Legacy observation without session id.\n"
"memory_type: procedural\n"
"scope: global\n"
"source:\n"
" type: turn\n"
" agent: EvoScientist\n"
"---\n"
"Legacy body text.\n",
encoding="utf-8",
)
documents = list_observation_documents(
memory_dir=memories,
project_id="P-project",
)
read = read_observation_file(
memory_dir=memories,
project_id="P-project",
observation_id="O-legacy",
)
hits = search_observation_files(
memory_dir=memories,
project_id="P-project",
query="Legacy body",
)
assert [document.observation_id for document in documents] == ["O-legacy"]
assert read is not None
assert read["observation_id"] == "O-legacy"
assert hits[0]["observation_id"] == "O-legacy"
def test_unquoted_naive_yaml_timestamp_does_not_claim_utc(tmp_path):
observation = tmp_path / "O-naive.md"
observation.write_text(
"---\n"
"id: O-naive\n"
"created_at: 2026-01-01 12:30:00\n"
"summary: Legacy observation with naive YAML timestamp.\n"
"memory_type: procedural\n"
"scope: global\n"
"source:\n"
" type: turn\n"
" agent: EvoScientist\n"
" session_id: thread-1\n"
"---\n"
"Legacy body text.\n",
encoding="utf-8",
)
document = read_observation_document(observation)
assert document is not None
metadata, _body = document
assert metadata.created_at == "2026-01-01T12:30:00"
def test_unquoted_aware_yaml_timestamp_normalizes_to_utc(tmp_path):
observation = tmp_path / "O-aware.md"
observation.write_text(
"---\n"
"id: O-aware\n"
"created_at: 2026-01-01 12:30:00+02:00\n"
"summary: Legacy observation with aware YAML timestamp.\n"
"memory_type: procedural\n"
"scope: global\n"
"source:\n"
" type: turn\n"
" agent: EvoScientist\n"
" session_id: thread-1\n"
"---\n"
"Legacy body text.\n",
encoding="utf-8",
)
document = read_observation_document(observation)
assert document is not None
metadata, _body = document
assert metadata.created_at == "2026-01-01T10:30:00Z"
def test_link_observation_files_keeps_supersedes_directional(tmp_path):
memories = tmp_path / "memories"
source = record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.SEMANTIC,
summary="New memory replaces older guidance.",
observation="Use the newer observation as the current guidance.",
why_it_matters="Future agents should prefer the replacement guidance.",
scope=MemoryScope.PROJECT,
source_type=MemorySourceType.TURN,
source_session_id="thread-1",
source_agent="EvoScientist",
)
target = record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.SEMANTIC,
summary="Older guidance is superseded.",
observation="This older observation should no longer be preferred.",
why_it_matters="Future agents need to avoid stale guidance.",
scope=MemoryScope.PROJECT,
source_type=MemorySourceType.TURN,
source_session_id="thread-1",
source_agent="EvoScientist",
)
result = link_observation_files(
memory_dir=memories,
project_id="P-project",
source_observation_id=source["observation_id"],
target_observation_id=target["observation_id"],
relation=ObservationRelation.SUPERSEDES,
reason="The source observation replaces the target observation.",
)
source_metadata, _source_body = _read_memory_document(
memories / source["path"].removeprefix("/memories/")
)
target_metadata, _target_body = _read_memory_document(
memories / target["path"].removeprefix("/memories/")
)
assert result["updated_observation_ids"] == [source["observation_id"]]
assert source_metadata["related_observations"] == [
{
"id": target["observation_id"],
"relation": "supersedes",
"reason": "The source observation replaces the target observation.",
"linked_at": source_metadata["related_observations"][0]["linked_at"],
}
]
assert "related_observations" not in target_metadata
def test_link_observation_files_rejects_unknown_relation(tmp_path):
memories = tmp_path / "memories"
with pytest.raises(ValueError, match="relation must be one of"):
link_observation_files(
memory_dir=memories,
project_id="P-project",
source_observation_id="O-source",
target_observation_id="O-target",
relation="overlaps",
reason="This unsupported relation should be rejected.",
)
def test_link_observations_tool_uses_runtime_project_id(tmp_path):
memories = tmp_path / "memories"
first = record_observation_file(
memory_dir=memories,
project_id="P-runtime",
memory_type=MemoryType.SEMANTIC,
summary="Runtime project id selects project memory.",
observation="Linking tools should honor runtime project ids.",
why_it_matters="Shared graph builds can still handle project memory.",
scope=MemoryScope.PROJECT,
source_type=MemorySourceType.TURN,
source_session_id="thread-1",
source_agent="EvoScientist",
)
second = record_observation_file(
memory_dir=memories,
project_id="P-runtime",
memory_type=MemoryType.SEMANTIC,
summary="Observation links live in frontmatter.",
observation="Frontmatter links are machine-readable.",
why_it_matters="Future status and search features can use metadata.",
scope=MemoryScope.PROJECT,
source_type=MemorySourceType.TURN,
source_session_id="thread-1",
source_agent="EvoScientist",
)
tool = create_link_observations_tool(
memory_dir=memories,
project_id="wrong-project",
)
runtime = _tool_runtime(
tool,
config={"configurable": {"evomemory_project_id": "P-runtime"}},
)
payload = json.loads(
tool.run(
{
"source_observation_id": first["observation_id"],
"target_observation_id": second["observation_id"],
"reason": "Both validate frontmatter-native linker behavior.",
"runtime": runtime,
}
)
)
assert payload["linked"] is True
metadata, _body = _read_memory_document(
memories / first["path"].removeprefix("/memories/")
)
assert metadata["related_observations"][0]["id"] == second["observation_id"]
def test_observation_linker_finish_counts_successful_relations_once(tmp_path):
memories = tmp_path / "memories"
first = record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.SEMANTIC,
summary="Linked relation counts are status-bar outcomes.",
observation="Successful link_observations calls should count relations.",
why_it_matters="The status bar should report durable link outcomes.",
scope=MemoryScope.PROJECT,
source_type=MemorySourceType.TURN,
source_session_id="thread-1",
source_agent="EvoScientist",
)
second = record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.SEMANTIC,
summary="Duplicate relation calls should be no-ops.",
observation="Duplicate link_observations calls should not recount links.",
why_it_matters="Relation counts should reflect actual metadata updates.",
scope=MemoryScope.PROJECT,
source_type=MemorySourceType.TURN,
source_session_id="thread-1",
source_agent="EvoScientist",
)
run = _observation_linker_run()
hooks = memory_launch._observation_linker_launch_hooks(memories)
assert hooks.on_before_run is not None
assert hooks.on_started is not None
assert hooks.on_finished is not None
hooks.on_before_run(run.thread_id)
hooks.on_started(run)
first_payload = link_observation_files(
memory_dir=memories,
project_id="P-project",
source_observation_id=first["observation_id"],
target_observation_id=second["observation_id"],
reason="Both validate status counting for durable links.",
)
duplicate_payload = link_observation_files(
memory_dir=memories,
project_id="P-project",
source_observation_id=first["observation_id"],
target_observation_id=second["observation_id"],
reason="Both validate status counting for durable links.",
)
hooks.on_finished(run)
status = worker_activity.observation_linker_status()
assert first_payload["linked"] is True
assert duplicate_payload["linked"] is False
assert status.relations_linked == 1
def test_observation_linker_finish_does_not_count_reason_only_updates(tmp_path):
memories = tmp_path / "memories"
first = record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.SEMANTIC,
summary="Relation identity ignores rationale text.",
observation="Changing a relation reason is not a newly created link.",
why_it_matters="The status bar should not inflate memory-link counts.",
scope=MemoryScope.PROJECT,
source_type=MemorySourceType.TURN,
source_session_id="thread-1",
source_agent="EvoScientist",
)
second = record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.SEMANTIC,
summary="Existing relation can receive a better rationale.",
observation="Linker reruns may refine the reason for an existing relation.",
why_it_matters="Refined metadata should not look like a new edge.",
scope=MemoryScope.PROJECT,
source_type=MemorySourceType.TURN,
source_session_id="thread-1",
source_agent="EvoScientist",
)
link_observation_files(
memory_dir=memories,
project_id="P-project",
source_observation_id=first["observation_id"],
target_observation_id=second["observation_id"],
reason="Initial rationale for the existing relation.",
)
run = _observation_linker_run()
hooks = memory_launch._observation_linker_launch_hooks(memories)
assert hooks.on_before_run is not None
assert hooks.on_started is not None
assert hooks.on_finished is not None
hooks.on_before_run(run.thread_id)
hooks.on_started(run)
payload = link_observation_files(
memory_dir=memories,
project_id="P-project",
source_observation_id=first["observation_id"],
target_observation_id=second["observation_id"],
reason="Updated rationale for the existing relation.",
)
hooks.on_finished(run)
assert payload["linked"] is True
assert worker_activity.observation_linker_status().relations_linked == 0
def test_read_and_search_observation_tools_use_runtime_project_id(tmp_path):
memories = tmp_path / "memories"
observation = record_observation_file(
memory_dir=memories,
project_id="P-runtime",
memory_type=MemoryType.SEMANTIC,
summary="Runtime project id selects observation reads.",
observation="Read and search tools should honor runtime project ids.",
why_it_matters="Shared graph builds can still inspect project memory.",
scope=MemoryScope.PROJECT,
source_type=MemorySourceType.TURN,
source_session_id="thread-1",
source_agent="EvoScientist",
)
search_tool = create_search_observations_tool(
memory_dir=memories,
project_id="wrong-project",
)
search_runtime = _tool_runtime(
search_tool,
config={"configurable": {"evomemory_project_id": "P-runtime"}},
)
search_payload = json.loads(
search_tool.run(
{
"query": "runtime project observation reads",
"scope": MemoryScope.PROJECT,
"runtime": search_runtime,
}
)
)
assert [hit["observation_id"] for hit in search_payload["results"]] == [
observation["observation_id"]
]
read_tool = create_read_memory_tool(
memory_dir=memories,
project_id="wrong-project",
)
read_runtime = _tool_runtime(
read_tool,
config={"configurable": {"evomemory_project_id": "P-runtime"}},
)
read_payload = json.loads(
read_tool.run(
{
"observation_id": observation["observation_id"],
"runtime": read_runtime,
}
)
)
assert (
"Read and search tools should honor runtime project ids."
in read_payload["text"]
)
def test_search_observation_files_returns_ranked_keyword_hits(tmp_path):
memories = tmp_path / "memories"
first = record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.PROCEDURAL,
summary="GraphQL resolver aliases preserve userName fields.",
observation=(
"When GraphQL returns blank camelCase fields, inspect resolver "
"aliases before changing the frontend query."
),
why_it_matters="Future profile tasks can avoid frontend-only fixes.",
scope=MemoryScope.GLOBAL,
source_type=MemorySourceType.SUBAGENT,
source_session_id="thread-1",
source_agent="code-agent",
)
second = record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.SEMANTIC,
summary="CSV date normalization can change ordering.",
observation="Normalize date strings before sorting cross-source reports.",
why_it_matters="Future data tasks should avoid lexicographic date sorting.",
scope=MemoryScope.PROJECT,
source_type=MemorySourceType.SUBAGENT,
source_session_id="thread-1",
source_agent="data-agent",
)
hits = search_observation_files(
memory_dir=memories,
project_id="P-project",
query="GraphQL userName frontend",
limit=5,
)
assert [hit["observation_id"] for hit in hits] == [first["observation_id"]]
assert hits[0]["path"] == first["path"]
assert hits[0]["memory_type"] == MemoryType.PROCEDURAL
assert hits[0]["scope"] == MemoryScope.GLOBAL
assert hits[0]["summary"] == "GraphQL resolver aliases preserve userName fields."
assert hits[0]["matches"] == [
(
"When GraphQL returns blank camelCase fields, inspect resolver aliases "
"before changing the frontend query."
),
"Future profile tasks can avoid frontend-only fixes.",
]
assert hits[0]["score"] > 0
assert (
search_observation_files(
memory_dir=memories,
project_id="P-project",
query="date|sorting",
scope=MemoryScope.PROJECT,
memory_type=MemoryType.SEMANTIC,
)[0]["observation_id"]
== second["observation_id"]
)
tool = create_search_observations_tool(
memory_dir=memories,
project_id="P-project",
)
payload = json.loads(tool.run({"query": "GraphQL userName frontend", "limit": 5}))
assert list(payload) == ["results"]
assert payload["results"][0]["observation_id"] == first["observation_id"]
def test_read_memory_returns_full_observation_by_id(tmp_path):
memories = tmp_path / "memories"
observation = "Read the full observation before applying a partial snippet."
result = record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.PROCEDURAL,
summary="Full memory reads prevent acting on partial snippets.",
observation=observation,
why_it_matters="Future agents can inspect the full rationale before editing.",
scope=MemoryScope.PROJECT,
source_type=MemorySourceType.SUBAGENT,
source_session_id="thread-1",
source_agent="code-agent",
)
read = read_observation_file(
memory_dir=memories,
project_id="P-project",
observation_id=result["observation_id"],
)
assert read is not None
assert read["observation_id"] == result["observation_id"]
assert read["path"] == result["path"]
assert read["memory_type"] == MemoryType.PROCEDURAL
assert read["scope"] == MemoryScope.PROJECT
assert read["summary"] == "Full memory reads prevent acting on partial snippets."
assert read["text"].startswith("---\n")
assert observation in read["text"]
tool = create_read_memory_tool(memory_dir=memories, project_id="P-project")
payload = json.loads(tool.run({"observation_id": result["observation_id"]}))
assert payload == {"text": read["text"]}
missing = json.loads(tool.run({"observation_id": "../not-a-memory"}))
assert missing == {
"error": "No observation with that ID exists in global or current-project memory.",
}
def test_search_observation_files_supports_keyword_or_regex_queries(tmp_path):
memories = tmp_path / "memories"
record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.SEMANTIC,
summary="Build command exits with a generic error.",
observation="The local build can fail with an error after dependency setup.",
why_it_matters="Future agents should inspect command output.",
scope=MemoryScope.GLOBAL,
source_type=MemorySourceType.SUBAGENT,
source_session_id="thread-1",
source_agent="code-agent",
)
record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.SEMANTIC,
summary="FastAPI version conflicts can block dependency resolution.",
observation="FastAPI and pydantic version constraints can make installs fail.",
why_it_matters="Future agents should inspect package constraints.",
scope=MemoryScope.GLOBAL,
source_type=MemorySourceType.SUBAGENT,
source_session_id="thread-1",
source_agent="code-agent",
)
relevant = record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.SEMANTIC,
summary="Silent-failure: backend status handling hides API response errors.",
observation=(
"When HTTP response status handling treats server errors as success, "
"frontend error states can collapse into ordinary empty data."
),
why_it_matters="Future agents should audit both HTTP status and UI state.",
scope=MemoryScope.GLOBAL,
source_type=MemorySourceType.SUBAGENT,
source_session_id="thread-1",
source_agent="code-agent",
)
variant_hits = search_observation_files(
memory_dir=memories,
project_id="P-project",
query="blank profile silent failure empty data not onboarded",
)
focused_hits = search_observation_files(
memory_dir=memories,
project_id="P-project",
mode=ObservationSearchMode.REGEX,
query="silent[- ]failure|status",
)
assert [hit["observation_id"] for hit in variant_hits] == [
relevant["observation_id"]
]
assert [hit["observation_id"] for hit in focused_hits] == [
relevant["observation_id"]
]
def test_search_observation_files_handles_regex_like_literals(tmp_path):
memories = tmp_path / "memories"
relevant = record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.PROCEDURAL,
summary="Literal [bracket token appears in build logs.",
observation="When logs include [bracket tokens, search should not crash.",
why_it_matters="Malformed model regex should still behave like literal grep.",
scope=MemoryScope.GLOBAL,
source_type=MemorySourceType.SUBAGENT,
source_session_id="thread-1",
source_agent="code-agent",
)
hits = search_observation_files(
memory_dir=memories,
project_id="P-project",
query="[bracket",
)
regex_hits = search_observation_files(
memory_dir=memories,
project_id="P-project",
query="[bracket",
mode=ObservationSearchMode.REGEX,
)
assert [hit["observation_id"] for hit in hits] == [relevant["observation_id"]]
assert [hit["observation_id"] for hit in regex_hits] == [relevant["observation_id"]]
def test_search_observation_files_ranks_bag_of_words_queries(tmp_path):
memories = tmp_path / "memories"
record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.SEMANTIC,
summary="CSV header normalization needs whitespace stripping.",
observation="Strip CSV headers before schema matching.",
why_it_matters="Future revenue imports may have messy column names.",
scope=MemoryScope.GLOBAL,
source_type=MemorySourceType.SUBAGENT,
source_session_id="thread-1",
source_agent="data-agent",
)
relevant = record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.SEMANTIC,
summary=(
"Batch duplicate detection by batch_id grouping misses cross-ID "
"imports; content fingerprinting is required."
),
observation=(
"Compute a stable content-fingerprint per batch from sorted "
"(date, amount) pairs before revenue aggregation."
),
why_it_matters=(
"Future quarterly revenue reports should collapse duplicate import "
"batches before totals are computed."
),
scope=MemoryScope.GLOBAL,
source_type=MemorySourceType.SUBAGENT,
source_session_id="thread-1",
source_agent="data-agent",
)
hits = search_observation_files(
memory_dir=memories,
project_id="P-project",
query="CSV duplicate batch fingerprint revenue quarterly",
limit=2,
)
assert hits[0]["observation_id"] == relevant["observation_id"]
assert hits[0]["score"] > hits[1]["score"]
def test_search_observation_files_returns_no_low_confidence_fallback(tmp_path):
memories = tmp_path / "memories"
record_observation_file(
memory_dir=memories,
project_id="P-project",
memory_type=MemoryType.SEMANTIC,
summary="CSV header normalization needs whitespace stripping.",
observation="Strip CSV headers before schema matching.",
why_it_matters="Future imports may have messy column names.",
scope=MemoryScope.GLOBAL,
source_type=MemorySourceType.SUBAGENT,
source_session_id="thread-1",
source_agent="data-agent",
)
hits = search_observation_files(
memory_dir=memories,
project_id="P-project",
query="quantum thermostat",
)
assert hits == []
def test_record_observation_tool_can_use_worker_config_source(tmp_path):
from EvoScientist.middleware.memory import create_memory_middleware
workspace = tmp_path / "workspace"
workspace.mkdir()
middleware = create_memory_middleware(
str(tmp_path / "memories"),
workspace_dir=workspace,
source_type=MemorySourceType.SUBAGENT,
source_agent="evomemory-subagent-worker",
)
tool = _tool_by_name(middleware.tools, "record_observation")
payload = _record_observation_payload(
tool,
runtime=_tool_runtime(
tool,
tool_call_id="tool-1",
config={
"configurable": {
"evomemory_project_id": "P-project",
"evomemory_source_agent": "writing-agent",
"evomemory_source_session_id": "thread-source",
"evomemory_trajectory_digest": "digest-source",
}
},
),
memory_type=MemoryType.PROCEDURAL,
summary="Worker observations retain source run attribution.",
observation="The worker should attribute observations to the source run.",
why_it_matters="Later debugging needs the original agent and thread.",
scope=MemoryScope.PROJECT,
)
path = tmp_path / "memories" / payload["path"].removeprefix("/memories/")
metadata, _body = _read_memory_document(path)
assert payload["project_id"] == "P-project"
assert _stable_created_at(metadata) == {
"id": payload["observation_id"],
"created_at": "<created_at>",
"summary": "Worker observations retain source run attribution.",
"memory_type": "procedural",
"scope": "project",
"project_id": "P-project",
"source": {
"type": "subagent",
"agent": "writing-agent",
"session_id": "thread-source",
},
}
def test_record_observation_tool_schema_hides_runtime(tmp_path):
from EvoScientist.middleware.memory import create_memory_middleware
workspace = tmp_path / "workspace"
workspace.mkdir()
middleware = create_memory_middleware(
str(tmp_path / "memories"),
workspace_dir=workspace,
)
tool = _tool_by_name(middleware.tools, "record_observation")
assert "runtime" in tool.get_input_schema().model_fields
schema = tool.tool_call_schema
assert isinstance(schema, type)
assert issubclass(schema, BaseModel)
assert sorted(schema.model_json_schema()["properties"]) == [
"evidence",
"memory_type",
"observation",
"scope",
"summary",
"why_it_matters",
]
def test_record_observation_tool_keeps_injected_runtime_through_validation(tmp_path):
from EvoScientist.middleware.memory import create_memory_middleware
workspace = tmp_path / "workspace"
workspace.mkdir()
middleware = create_memory_middleware(
str(tmp_path / "memories"),
workspace_dir=workspace,
source_type=MemorySourceType.TURN,
source_agent="EvoScientist",
)
tool = _tool_by_name(middleware.tools, "record_observation")
payload = _record_observation_payload(
tool,
runtime=_tool_runtime(
tool,
config={"configurable": {"thread_id": "thread-from-runtime"}},
tool_call_id="tool-1",
),
memory_type=MemoryType.SEMANTIC,
summary="Injected runtime metadata survives tool validation.",
observation="Runtime survives validation.",
why_it_matters="Observation metadata should keep the live thread.",
scope=MemoryScope.GLOBAL,
)
path = tmp_path / "memories" / payload["path"].removeprefix("/memories/")
metadata, _body = _read_memory_document(path)
assert _stable_created_at(metadata) == {
"id": payload["observation_id"],
"created_at": "<created_at>",
"summary": "Injected runtime metadata survives tool validation.",
"memory_type": "semantic",
"scope": "global",
"source": {
"type": "turn",
"agent": "EvoScientist",
"session_id": "thread-from-runtime",
},
}
def test_record_observation_tool_skips_without_runtime_thread_id(tmp_path):
from EvoScientist.middleware.memory import create_memory_middleware
workspace = tmp_path / "workspace"
workspace.mkdir()
launched: list[memory_scheduler.ObservationLinkerContext] = []
coordinator = memory_scheduler.MemoryScheduler(launch_linker=launched.append)
middleware = create_memory_middleware(
str(tmp_path / "memories"),
workspace_dir=workspace,
memory_scheduler=coordinator,
)
tool = _tool_by_name(middleware.tools, "record_observation")
payload = _record_observation_payload(
tool,
runtime=_tool_runtime(tool),
memory_type=MemoryType.SEMANTIC,
summary="Unthreaded observations are skipped.",
observation="Observation recording needs source session provenance.",
why_it_matters="Durable memory should not persist unknown source sessions.",
scope=MemoryScope.GLOBAL,
)
assert payload == {
"error": "Cannot record observation without a source session id.",
}
assert launched == []
assert list((tmp_path / "memories").glob("observations/**/*.md")) == []
def test_direct_record_observation_queues_linking_until_worker_finish(tmp_path):
from EvoScientist.middleware.memory import create_memory_middleware
memory_dir = tmp_path / "memories"
workspace = tmp_path / "workspace"
workspace.mkdir()
launched: list[memory_scheduler.ObservationLinkerContext] = []
coordinator = memory_scheduler.MemoryScheduler(launch_linker=launched.append)
middleware = create_memory_middleware(
str(memory_dir),
workspace_dir=workspace,
memory_scheduler=coordinator,
)
tool = _tool_by_name(middleware.tools, "record_observation")
payload = _record_observation_payload(
tool,
runtime=_tool_runtime(tool, thread_id="source-thread"),
memory_type=MemoryType.PROCEDURAL,
summary="Direct observations link after worker finish.",
observation=(
"Direct main-agent observations should be linked after the "
"post-turn memory worker phase finishes."
),
why_it_matters=(
"The linker should see direct writes even when the worker "
"does not create another observation."
),
scope=MemoryScope.PROJECT,
)
assert payload["created"] is True
assert launched == []
hooks = memory_launch._memory_worker_launch_hooks(
memory_dir,
on_worker_finished=coordinator.record_worker_finished,
)
assert hooks.on_before_run is not None
assert hooks.on_started is not None
assert hooks.on_finished is not None
hooks.on_before_run("worker-thread")
worker_run = _memory_worker_run(
thread_id="worker-thread",
run_id="worker-run",
workspace_dir=str(workspace),
project_id=middleware.project_id,
)
hooks.on_started(worker_run)
assert launched == []
hooks.on_finished(worker_run)
assert launched == [
_linker_context(
memory_dir=memory_dir,
workspace_dir=workspace,
project_id=middleware.project_id,
observation_ids=(payload["observation_id"],),
)
]
status = worker_activity.memory_worker_status()
assert status.observations_recorded == 0
def test_turn_compaction_hides_task_call_and_keeps_orchestrator_response():
messages = [
HumanMessage("please delegate"),
AIMessage(
content="",
name="EvoScientist",
tool_calls=[
{
"name": "task",
"id": "task-1",
"args": {"subagent_type": "code-agent", "description": "debug"},
}
],
),
ToolMessage("raw subagent result body", tool_call_id="task-1"),
AIMessage(
"final orchestrator text with summarized finding", name="EvoScientist"
),
]
compact = source_context._compact_turn_messages(
messages,
source_agent="EvoScientist",
)
assert compact == [
{"role": "human", "content": "please delegate"},
{
"role": "ai",
"content": "final orchestrator text with summarized finding",
"name": "EvoScientist",
},
]
def test_turn_compaction_keeps_direct_tool_results_with_tool_names():
messages = [
HumanMessage("run a check"),
AIMessage(
content="",
name="EvoScientist",
tool_calls=[
{
"name": "execute",
"id": "exec-1",
"args": {"command": "pytest -q"},
},
{
"name": "task",
"id": "task-1",
"args": {"subagent_type": "code-agent", "description": "debug"},
},
],
),
ToolMessage("pytest passed", tool_call_id="exec-1", name="execute"),
ToolMessage("raw subagent result body", tool_call_id="task-1", name="task"),
AIMessage("final answer", name="EvoScientist"),
]
compact = source_context._compact_turn_messages(
messages,
source_agent="EvoScientist",
)
assert compact == [
{"role": "human", "content": "run a check"},
{
"role": "ai",
"content": "",
"name": "EvoScientist",
"tool_calls": [
{
"name": "execute",
"id": "exec-1",
"args": {"command": "pytest -q"},
"type": "tool_call",
},
],
},
{
"role": "tool",
"content": "pytest passed",
"name": "execute",
"tool_call_id": "exec-1",
"status": "success",
},
{"role": "ai", "content": "final answer", "name": "EvoScientist"},
]
def test_turn_compaction_uses_latest_user_turn_only():
messages = [
HumanMessage("old request"),
AIMessage("old answer", name="EvoScientist"),
HumanMessage("current request"),
AIMessage("current answer", name="EvoScientist"),
]
compact = source_context._compact_turn_messages(
messages,
source_agent="EvoScientist",
)
assert compact == [
{"role": "human", "content": "current request"},
{"role": "ai", "content": "current answer", "name": "EvoScientist"},
]
async def test_lifecycle_schedules_turn_worker_without_awaiting(tmp_path, monkeypatch):
memory_dir = tmp_path / "memories"
workspace_dir = tmp_path / "workspace"
calls = []
launched: list[memory_scheduler.ObservationLinkerContext] = []
coordinator = memory_scheduler.MemoryScheduler(launch_linker=launched.append)
async def fake_launch(request, **kwargs):
calls.append((request, kwargs["hooks"]))
monkeypatch.setattr(
memory_launch,
"alaunch_background_run",
fake_launch,
)
middleware = memory_lifecycle.EvoMemoryLifecycleMiddleware(
memory_dir=memory_dir,
workspace_dir=workspace_dir,
project_id="P-project",
source_type=MemorySourceType.TURN,
source_agent="EvoScientist",
memory_scheduler=coordinator,
)
runtime = _runtime("thread-1")
state: AgentState[object] = {
"messages": [
HumanMessage("previous turn"),
AIMessage("previous answer"),
HumanMessage("hi"),
AIMessage("done"),
]
}
await middleware.aafter_agent(
state,
runtime,
)
assert len(calls) == 1
request, hooks = calls[0]
assert request.graph_id == memory_launch.TURN_MEMORY_WORKER_GRAPH_ID
assert request.name == "EvoMemory worker"
assert hooks.on_before_run is not None
assert hooks.on_started is not None
assert hooks.on_finished is not None
hooks.on_before_run("worker-thread")
worker_run = _memory_worker_run(workspace_dir=str(workspace_dir))
hooks.on_started(worker_run)
observation = _record_test_observation(memory_dir)
hooks.on_finished(worker_run)
assert launched == [
_linker_context(
memory_dir=memory_dir,
workspace_dir=workspace_dir,
observation_ids=(observation["observation_id"],),
)
]
def test_lifecycle_skips_memory_worker_without_runtime_thread_id(tmp_path, monkeypatch):
def fail_launch(*_args, **_kwargs):
raise AssertionError("worker should not launch without a source thread id")
monkeypatch.setattr(memory_lifecycle, "launch_memory_worker", fail_launch)
middleware = memory_lifecycle.EvoMemoryLifecycleMiddleware(
memory_dir=tmp_path / "memories",
workspace_dir=tmp_path / "workspace",
project_id="P-project",
source_type=MemorySourceType.TURN,
source_agent="EvoScientist",
)
middleware.after_agent(
{"messages": [HumanMessage("hi"), AIMessage("done", name="EvoScientist")]},
_runtime(),
)
def test_subagent_summary_writer_uses_worker_metadata(tmp_path, monkeypatch):
summary = "Completed the analysis."
monkeypatch.setattr(
memory_worker,
"_current_configurable",
lambda: {
"evomemory_source_session_id": "thread-1",
"evomemory_source_agent": "writing-agent",
"evomemory_project_id": "P-project",
"evomemory_trajectory_digest": "digest-1",
},
)
middleware = memory_worker._SubagentSummaryWriterMiddleware(
memory_dir=tmp_path / "memories"
)
state: AgentState[object] = {
"messages": [],
"structured_response": memory_worker.SubagentMemoryDecision(summary=summary),
}
middleware.after_agent(
state,
_runtime(),
)
paths = list((tmp_path / "memories" / "executions" / "thread-1").glob("*.md"))
assert len(paths) == 1
metadata, body = _read_memory_document(paths[0])
assert _stable_created_at(metadata) == {
"id": memory_worker._execution_summary_id(
session_id="thread-1",
source_agent="writing-agent",
trajectory_digest="digest-1",
),
"created_at": "<created_at>",
"source": {
"type": "subagent",
"session_id": "thread-1",
"agent": "writing-agent",
},
"project_id": "P-project",
}
assert _markdown_sections(body) == {"Summary": summary}
def test_memory_worker_run_payload_use_server_thread_id_and_source_metadata(
monkeypatch,
):
monkeypatch.setattr(
memory_launch,
"_worker_workspace_dir",
lambda _workspace_dir: "/tmp/ws",
)
trajectory: list[source_context.CompactMessage] = [
{"role": "human", "content": "hi"}
]
context = _memory_source_context(
memory_dir="/memories",
workspace_dir="/active/workspace",
source_type=MemorySourceType.SUBAGENT,
source_agent="writing-agent",
trajectory=trajectory,
)
kwargs = memory_launch._memory_worker_run_payload(
context=context,
thread_id="worker-thread",
)
assert kwargs["assistant_id"] == memory_launch.SUBAGENT_MEMORY_WORKER_GRAPH_ID
assert kwargs["metadata"] == {
"run_kind": "evomemory_subagent_worker",
"source_session_id": "thread-1",
"source_agent": "writing-agent",
"project_id": "P-project",
"trajectory_digest": source_context._trajectory_digest(trajectory),
"workspace_dir": "/tmp/ws",
}
configurable = kwargs["config"]["configurable"]
assert configurable["thread_id"] == "worker-thread"
assert {
key: value
for key, value in configurable.items()
if key.startswith("evomemory_")
} == {
"evomemory_source_session_id": "thread-1",
"evomemory_source_agent": "writing-agent",
"evomemory_project_id": "P-project",
"evomemory_trajectory_digest": source_context._trajectory_digest(trajectory),
}
def test_memory_worker_finish_launches_linker_for_new_observations(
tmp_path,
):
memory_dir = tmp_path / "memories"
workspace_dir = tmp_path / "workspace"
launched: list[memory_scheduler.ObservationLinkerContext] = []
coordinator = memory_scheduler.MemoryScheduler(launch_linker=launched.append)
_mark_worker_started(memory_dir)
observation = _record_test_observation(memory_dir)
hooks = memory_launch._memory_worker_launch_hooks(
memory_dir,
on_worker_finished=coordinator.record_worker_finished,
)
assert hooks.on_finished is not None
hooks.on_finished(
_memory_worker_run(workspace_dir=str(workspace_dir), run_id="run-1")
)
assert launched == [
_linker_context(
memory_dir=memory_dir,
workspace_dir=workspace_dir,
observation_ids=(observation["observation_id"],),
)
]
assert worker_activity.memory_worker_status().observations_recorded == 1
def test_memory_worker_linker_waits_for_active_workers_and_batches_observations(
tmp_path,
):
memory_dir = tmp_path / "memories"
workspace_dir = tmp_path / "workspace"
launched: list[memory_scheduler.ObservationLinkerContext] = []
coordinator = memory_scheduler.MemoryScheduler(launch_linker=launched.append)
before = worker_activity.snapshot_memory_outputs(memory_dir)
_mark_worker_started(
memory_dir,
thread_id="thread-1",
run_id="run-1",
before_outputs=before,
)
_mark_worker_started(
memory_dir,
thread_id="thread-2",
run_id="run-2",
before_outputs=before,
)
first_observation = _record_test_observation(
memory_dir,
summary="First durable observation.",
observation="The first reusable observation for linking.",
)
hooks = memory_launch._memory_worker_launch_hooks(
memory_dir,
on_worker_finished=coordinator.record_worker_finished,
)
assert hooks.on_finished is not None
hooks.on_finished(
_memory_worker_run(
thread_id="thread-1",
run_id="run-1",
workspace_dir=str(workspace_dir),
source_agent="subagent-a",
source_session_id="session-a",
trajectory_digest="digest-a",
)
)
assert launched == []
second_observation = _record_test_observation(
memory_dir,
summary="Second durable observation.",
observation="The second reusable observation for linking.",
scope=MemoryScope.PROJECT,
)
hooks.on_finished(
_memory_worker_run(
thread_id="thread-2",
run_id="run-2",
workspace_dir=str(workspace_dir),
source_agent="EvoScientist",
source_session_id="session-b",
trajectory_digest="digest-b",
)
)
assert len(launched) == 1
assert launched[0].memory_dir == memory_dir
assert launched[0].workspace_dir == workspace_dir
assert launched[0].project_id == "P-project"
assert set(launched[0].observation_ids) == {
first_observation["observation_id"],
second_observation["observation_id"],
}
assert worker_activity.memory_worker_status().observations_recorded == 2
def test_memory_worker_linker_flushes_when_last_worker_has_no_observations(tmp_path):
memory_dir = tmp_path / "memories"
workspace_dir = tmp_path / "workspace"
launched: list[memory_scheduler.ObservationLinkerContext] = []
coordinator = memory_scheduler.MemoryScheduler(launch_linker=launched.append)
before = worker_activity.snapshot_memory_outputs(memory_dir)
_mark_worker_started(
memory_dir,
thread_id="thread-1",
run_id="run-1",
before_outputs=before,
)
_mark_worker_started(
memory_dir,
thread_id="thread-2",
run_id="run-2",
before_outputs=before,
)
observation = _record_test_observation(memory_dir)
hooks = memory_launch._memory_worker_launch_hooks(
memory_dir,
on_worker_finished=coordinator.record_worker_finished,
)
assert hooks.on_finished is not None
hooks.on_finished(
_memory_worker_run(
thread_id="thread-1",
run_id="run-1",
workspace_dir=str(workspace_dir),
)
)
assert launched == []
profile_path = memory_dir / "profile" / "USER_PROFILE.md"
profile_path.parent.mkdir(parents=True)
profile_path.write_text("# User profile\n\n- remembered\n", encoding="utf-8")
hooks.on_finished(
_memory_worker_run(
thread_id="thread-2",
run_id="run-2",
workspace_dir=str(workspace_dir),
)
)
assert len(launched) == 1
assert launched[0].observation_ids == (observation["observation_id"],)
status = worker_activity.memory_worker_status()
assert status.profile_updates == 1
assert status.observations_recorded == 1
def test_memory_worker_linker_flushes_pending_batch_when_last_worker_aborts(tmp_path):
memory_dir = tmp_path / "memories"
workspace_dir = tmp_path / "workspace"
launched: list[memory_scheduler.ObservationLinkerContext] = []
coordinator = memory_scheduler.MemoryScheduler(launch_linker=launched.append)
before = worker_activity.snapshot_memory_outputs(memory_dir)
_mark_worker_started(
memory_dir,
thread_id="thread-1",
run_id="run-1",
before_outputs=before,
)
_mark_worker_started(
memory_dir,
thread_id="thread-2",
run_id="run-2",
before_outputs=before,
)
observation = _record_test_observation(memory_dir)
hooks = memory_launch._memory_worker_launch_hooks(
memory_dir,
on_worker_finished=coordinator.record_worker_finished,
on_worker_aborted=coordinator.record_worker_aborted,
)
assert hooks.on_finished is not None
assert hooks.on_aborted is not None
hooks.on_finished(
_memory_worker_run(
thread_id="thread-1",
run_id="run-1",
workspace_dir=str(workspace_dir),
)
)
assert launched == []
hooks.on_aborted(
_memory_worker_run(
thread_id="thread-2",
run_id="run-2",
workspace_dir=str(workspace_dir),
)
)
assert launched == [
_linker_context(
memory_dir=memory_dir,
workspace_dir=workspace_dir,
observation_ids=(observation["observation_id"],),
)
]
status = worker_activity.memory_worker_status()
assert status.is_running is False
assert status.observations_recorded == 1
def test_memory_worker_finish_does_not_launch_linker_for_profile_only_delta(
tmp_path,
):
memory_dir = tmp_path / "memories"
launched: list[memory_scheduler.ObservationLinkerContext] = []
coordinator = memory_scheduler.MemoryScheduler(launch_linker=launched.append)
_mark_worker_started(memory_dir)
profile_path = memory_dir / "profile" / "USER_PROFILE.md"
profile_path.parent.mkdir(parents=True)
profile_path.write_text("# User profile\n\n- remembered\n", encoding="utf-8")
hooks = memory_launch._memory_worker_launch_hooks(
memory_dir,
on_worker_finished=coordinator.record_worker_finished,
)
assert hooks.on_finished is not None
hooks.on_finished(_memory_worker_run(run_id="run-1"))
assert launched == []
status = worker_activity.memory_worker_status()
assert status.profile_updates == 1
assert status.observations_recorded == 0
def test_memory_worker_abort_queues_written_observations_for_linking(tmp_path):
memory_dir = tmp_path / "memories"
workspace_dir = tmp_path / "workspace"
launched: list[memory_scheduler.ObservationLinkerContext] = []
coordinator = memory_scheduler.MemoryScheduler(launch_linker=launched.append)
_mark_worker_started(memory_dir)
observation = _record_test_observation(memory_dir)
hooks = memory_launch._memory_worker_launch_hooks(
memory_dir,
on_worker_aborted=coordinator.record_worker_aborted,
)
assert hooks.on_aborted is not None
hooks.on_aborted(
_memory_worker_run(
run_id="run-1",
workspace_dir=str(workspace_dir),
)
)
assert launched == [
_linker_context(
memory_dir=memory_dir,
workspace_dir=workspace_dir,
observation_ids=(observation["observation_id"],),
)
]
status = worker_activity.memory_worker_status()
assert status.is_running is False
assert status.observations_recorded == 1
def test_memory_worker_watcher_start_failure_queues_written_observations_for_linking(
tmp_path,
):
memory_dir = tmp_path / "memories"
workspace_dir = tmp_path / "workspace"
launched: list[memory_scheduler.ObservationLinkerContext] = []
coordinator = memory_scheduler.MemoryScheduler(launch_linker=launched.append)
hooks = memory_launch._memory_worker_launch_hooks(
memory_dir,
on_worker_aborted=coordinator.record_worker_aborted,
)
worker_run = _memory_worker_run(
thread_id="worker-thread",
run_id="run-1",
workspace_dir=str(workspace_dir),
)
assert hooks.on_before_run is not None
assert hooks.on_started is not None
assert hooks.on_watcher_start_failed is not None
hooks.on_before_run(worker_run.thread_id)
hooks.on_started(worker_run)
observation = _record_test_observation(memory_dir)
hooks.on_watcher_start_failed(worker_run)
assert launched == [
_linker_context(
memory_dir=memory_dir,
workspace_dir=workspace_dir,
observation_ids=(observation["observation_id"],),
)
]
status = worker_activity.memory_worker_status()
assert status.is_running is False
assert status.observations_recorded == 1
def test_observation_linker_launch_request_encodes_batch_context(tmp_path):
context = _linker_context(
memory_dir=tmp_path / "memories",
workspace_dir=tmp_path / "workspace",
observation_ids=("O-2", "O-1"),
)
request = memory_launch.observation_linker_launch_request(context)
kwargs = request.run_payload("linker-thread")
assert request.graph_id == memory_launch.OBSERVATION_LINKER_GRAPH_ID
assert request.name == "EvoMemory observation linker"
configurable = kwargs["config"]["configurable"]
assert configurable["thread_id"] == "linker-thread"
assert configurable["evomemory_project_id"] == "P-project"
assert json.loads(configurable["evomemory_observation_ids"]) == [
"O-2",
"O-1",
]
def test_observation_linker_does_not_launch_when_observations_disabled(
tmp_path,
monkeypatch,
):
context = _linker_context(
memory_dir=tmp_path / "memories",
workspace_dir=tmp_path / "workspace",
observation_ids=("O-1",),
)
monkeypatch.setattr(
memory_launch,
"get_effective_config",
lambda: EvoScientistConfig(memory_observations_enabled=False),
)
launch_call = MagicMock()
monkeypatch.setattr(memory_launch, "launch_background_run", launch_call)
run = memory_launch.launch_observation_linker(context)
assert run is None
launch_call.assert_not_called()
async def test_async_observation_linker_does_not_launch_when_observations_disabled(
tmp_path,
monkeypatch,
):
context = _linker_context(
memory_dir=tmp_path / "memories",
workspace_dir=tmp_path / "workspace",
observation_ids=("O-1",),
)
monkeypatch.setattr(
memory_launch,
"get_effective_config",
lambda: EvoScientistConfig(memory_observations_enabled=False),
)
launch_call = MagicMock()
monkeypatch.setattr(memory_launch, "alaunch_background_run", launch_call)
run = await memory_launch.alaunch_observation_linker(context)
assert run is None
launch_call.assert_not_called()
def test_observation_linker_launch_hooks_track_running_status(tmp_path):
run = _observation_linker_run()
hooks = memory_launch._observation_linker_launch_hooks(tmp_path / "memories")
assert hooks.on_started is not None
assert hooks.on_finished is not None
hooks.on_started(run)
assert worker_activity.observation_linker_status().is_running is True
hooks.on_finished(run)
assert worker_activity.observation_linker_status().is_running is False
def test_observation_linker_uses_read_search_memory_and_link_tool(tmp_path):
tools = observation_linker._observation_linker_tools(
memory_dir=tmp_path / "memories",
workspace_dir=tmp_path / "workspace",
)
assert [tool.name for tool in tools] == [
"search_observations",
"read_memory",
"link_observations",
]
assert "record_observation" not in {tool.name for tool in tools}
def test_memory_worker_accepts_roots_at_build_time(tmp_path, monkeypatch):
calls = []
def fake_build(**kwargs):
calls.append(kwargs)
return MagicMock()
monkeypatch.setattr(memory_worker, "_build_memory_worker_agent", fake_build)
memory_worker.build_memory_worker_graph(
MemorySourceType.TURN,
memory_dir=tmp_path / "memories",
workspace_dir=tmp_path / "workspace",
)
assert calls[0]["memory_dir"] == tmp_path / "memories"
assert calls[0]["workspace_dir"] == tmp_path / "workspace"
def _memory_tool_names(middleware) -> list[str]:
memory_middleware = next(item for item in middleware if getattr(item, "tools", ()))
return [tool.name for tool in memory_middleware.tools]
@pytest.mark.parametrize(
("source_type", "observation_writer", "expected_tools"),
[
(
MemorySourceType.SUBAGENT,
MemoryObservationWriter.AGENT,
["search_observations", "read_memory"],
),
(
MemorySourceType.SUBAGENT,
MemoryObservationWriter.WORKER,
["search_observations", "read_memory", "record_observation"],
),
(
MemorySourceType.TURN,
MemoryObservationWriter.WORKER,
["search_observations", "read_memory", "record_observation"],
),
(
MemorySourceType.TURN,
MemoryObservationWriter.ALL,
["search_observations", "read_memory", "record_observation"],
),
(
MemorySourceType.SUBAGENT,
MemoryObservationWriter.ALL,
["search_observations", "read_memory", "record_observation"],
),
],
)
def test_memory_worker_observation_writer_modes(
tmp_path,
source_type: MemorySourceType,
observation_writer: MemoryObservationWriter,
expected_tools: list[str],
):
middleware = memory_worker._memory_worker_middleware(
memory_dir=tmp_path / "memories",
workspace_dir=tmp_path / "workspace",
source_type=source_type,
observation_writer=observation_writer,
)
# ErrorNormalizationMiddleware wraps outermost so provider-SDK
# exceptions from the auxiliary model call get normalized before
# the tool-error handler sees them.
assert type(middleware[0]).__name__ == "ErrorNormalizationMiddleware"
assert type(middleware[1]).__name__ == "ToolErrorHandlerMiddleware"
assert _memory_tool_names(middleware) == expected_tools
def test_sync_memory_worker_watcher_untracks_without_counting_on_poll_abort(
tmp_path, monkeypatch
):
memory_dir = tmp_path / "memories"
_mark_worker_started(memory_dir)
profile_path = memory_dir / "profile" / "USER_PROFILE.md"
profile_path.parent.mkdir(parents=True)
profile_path.write_text("# User profile\n\n- later update\n", encoding="utf-8")
class _Runs:
def get(self, **_kwargs):
raise RuntimeError("poll failed")
monkeypatch.setattr(
"langgraph_sdk.get_sync_client",
lambda **_kwargs: SimpleNamespace(runs=_Runs()),
)
background_runs.watch_background_run_sync(
url="http://x",
thread_id="worker-thread",
run_id="run-1",
hooks=memory_launch._memory_worker_launch_hooks(memory_dir),
watcher_config=_fast_watcher_config(max_poll_failures=1),
)
status = worker_activity.memory_worker_status()
assert status.is_running is False
assert status.profile_updates == 0
assert status.observations_recorded == 0
async def test_async_memory_worker_watcher_untracks_without_counting_on_poll_abort(
tmp_path, monkeypatch
):
memory_dir = tmp_path / "memories"
_mark_worker_started(memory_dir)
observation_path = memory_dir / "observations" / "global" / "O-1.md"
observation_path.parent.mkdir(parents=True)
observation_path.write_text("# Observation\n", encoding="utf-8")
class _Runs:
async def get(self, **_kwargs):
raise RuntimeError("poll failed")
await background_runs.awatch_background_run(
SimpleNamespace(runs=_Runs()),
thread_id="worker-thread",
run_id="run-1",
hooks=memory_launch._memory_worker_launch_hooks(memory_dir),
watcher_config=_fast_watcher_config(max_poll_failures=1),
)
status = worker_activity.memory_worker_status()
assert status.is_running is False
assert status.profile_updates == 0
assert status.observations_recorded == 0
async def test_async_memory_worker_watcher_counts_completion_under_blockbuster(
tmp_path,
):
memory_dir = tmp_path / "memories"
_mark_worker_started(memory_dir)
profile_path = memory_dir / "profile" / "USER_PROFILE.md"
profile_path.parent.mkdir(parents=True)
profile_path.write_text("# User profile\n\n- later update\n", encoding="utf-8")
class _Runs:
async def get(self, **_kwargs):
return {"status": "success"}
blocker = BlockBuster(scanned_modules=[memory_worker, worker_activity])
blocker.activate()
try:
await background_runs.awatch_background_run(
SimpleNamespace(runs=_Runs()),
thread_id="worker-thread",
run_id="run-1",
hooks=memory_launch._memory_worker_launch_hooks(memory_dir),
)
finally:
blocker.deactivate()
status = worker_activity.memory_worker_status()
assert status.is_running is False
assert status.profile_updates == 1
assert status.observations_recorded == 0
def test_memory_worker_watcher_untracks_when_client_creation_fails(
tmp_path, monkeypatch
):
memory_dir = tmp_path / "memories"
_mark_worker_started(memory_dir)
profile_path = memory_dir / "profile" / "USER_PROFILE.md"
profile_path.parent.mkdir(parents=True)
profile_path.write_text("# User profile\n\n- later update\n", encoding="utf-8")
monkeypatch.setattr(
"langgraph_sdk.get_sync_client",
lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("client failed")),
)
with pytest.raises(RuntimeError, match="client failed"):
background_runs.watch_background_run_sync(
url="http://x",
thread_id="worker-thread",
run_id="run-1",
hooks=memory_launch._memory_worker_launch_hooks(memory_dir),
)
status = worker_activity.memory_worker_status()
assert status.is_running is False
assert status.profile_updates == 0
assert status.observations_recorded == 0
def test_memory_worker_skips_when_langgraph_dev_unavailable(tmp_path, monkeypatch):
monkeypatch.setattr(
background_runs, "default_background_run_url", lambda: "http://x"
)
monkeypatch.setattr(
"EvoScientist.langgraph_dev.manager.is_langgraph_dev_running",
lambda **_kwargs: False,
)
def fail_get_sync_client(*_args, **_kwargs):
raise AssertionError("client should not be created")
monkeypatch.setattr("langgraph_sdk.get_sync_client", fail_get_sync_client)
middleware = memory_lifecycle.EvoMemoryLifecycleMiddleware(
memory_dir=tmp_path / "memories",
workspace_dir=tmp_path / "workspace",
project_id="P-project",
source_type=MemorySourceType.TURN,
source_agent="EvoScientist",
)
middleware.after_agent(
{"messages": [HumanMessage("hi"), AIMessage("done", name="EvoScientist")]},
_runtime("thread-1"),
)
def test_memory_worker_marks_active_status(tmp_path, monkeypatch):
monkeypatch.setattr(
background_runs, "default_background_run_url", lambda: "http://x"
)
monkeypatch.setattr(
memory_launch,
"_worker_workspace_dir",
lambda _workspace_dir: "/tmp/ws",
)
monkeypatch.setattr(
"EvoScientist.langgraph_dev.manager.is_langgraph_dev_running",
lambda **_kwargs: True,
)
fake_client = MagicMock()
fake_client.threads.create.return_value = {"thread_id": "worker-thread"}
fake_client.runs.create.return_value = {"run_id": "run-1", "status": "pending"}
monkeypatch.setattr("langgraph_sdk.get_sync_client", lambda **_kwargs: fake_client)
spawned: list[background_runs.BackgroundRun] = []
trajectory: list[source_context.CompactMessage] = [
{"role": "human", "content": "hi"}
]
memory_dir = tmp_path / "memories"
context = _memory_source_context(
memory_dir=memory_dir,
workspace_dir=tmp_path / "workspace",
trajectory=trajectory,
)
request = memory_launch.memory_worker_launch_request(context)
background_runs.launch_background_run(
request,
hooks=memory_launch._memory_worker_launch_hooks(memory_dir),
spawn_status_watcher=spawned.append,
)
assert worker_activity.memory_worker_status().is_running is True
expected_metadata = {
"run_kind": "evomemory_turn_worker",
"source_session_id": "thread-1",
"source_agent": "EvoScientist",
"project_id": "P-project",
"trajectory_digest": source_context._trajectory_digest(trajectory),
"workspace_dir": "/tmp/ws",
}
fake_client.threads.create.assert_called_once_with(
graph_id=memory_launch.TURN_MEMORY_WORKER_GRAPH_ID,
metadata=expected_metadata,
)
fake_client.runs.create.assert_called_once()
run_kwargs = fake_client.runs.create.call_args.kwargs
assert run_kwargs["thread_id"] == "worker-thread"
assert run_kwargs["metadata"] == expected_metadata
assert run_kwargs["config"]["configurable"]["thread_id"] == "worker-thread"
assert [(run.url, run.thread_id, run.run_id) for run in spawned] == [
("http://x", "worker-thread", "run-1")
]
profile_path = memory_dir / "profile" / "USER_PROFILE.md"
profile_path.parent.mkdir(parents=True)
profile_path.write_text("# User profile\n\n- remembered\n", encoding="utf-8")
observation_path = memory_dir / "observations" / "global" / "O-1.md"
observation_path.parent.mkdir(parents=True)
observation_path.write_text("# Observation\n", encoding="utf-8")
delta = worker_activity.mark_memory_worker_finished("worker-thread", "run-1")
status = worker_activity.memory_worker_status()
assert delta == worker_activity.MemoryOutputDelta(
memory_dir=memory_dir,
profile_paths=("profile/USER_PROFILE.md",),
observation_paths=("observations/global/O-1.md",),
)
assert status.is_running is False
assert status.profile_updates == 1
assert status.observations_recorded == 1
async def test_async_memory_worker_offloads_blocking_work(tmp_path, monkeypatch):
monkeypatch.setattr(
background_runs, "default_background_run_url", lambda: "http://x"
)
monkeypatch.setattr(
memory_launch,
"_worker_workspace_dir",
lambda _workspace_dir: "/tmp/ws",
)
call_threads: list[tuple[str, int]] = []
def fake_is_running(**_kwargs):
call_threads.append(("health", threading.get_ident()))
return True
def fake_snapshot(_memory_dir):
call_threads.append(("snapshot", threading.get_ident()))
return worker_activity.MemoryOutputSnapshot(
profile_files={},
observation_files=frozenset(),
)
monkeypatch.setattr(
"EvoScientist.langgraph_dev.manager.is_langgraph_dev_running",
fake_is_running,
)
monkeypatch.setattr(memory_launch, "snapshot_memory_outputs", fake_snapshot)
class _Threads:
async def create(self, **_kwargs):
return {"thread_id": "worker-thread"}
class _Runs:
async def create(self, **_kwargs):
return {"run_id": "run-1", "status": "pending"}
fake_client = SimpleNamespace(threads=_Threads(), runs=_Runs())
monkeypatch.setattr("langgraph_sdk.get_client", lambda **_kwargs: fake_client)
spawned: list[background_runs.BackgroundRun] = []
event_loop_thread = threading.get_ident()
context = _memory_source_context(
memory_dir=tmp_path / "memories",
workspace_dir=tmp_path / "workspace",
trajectory=[{"role": "human", "content": "hi"}],
)
request = memory_launch.memory_worker_launch_request(context)
await background_runs.alaunch_background_run(
request,
hooks=memory_launch._memory_worker_launch_hooks(tmp_path / "memories"),
spawn_status_watcher=spawned.append,
)
assert [name for name, _thread_id in call_threads] == ["health", "snapshot"]
assert all(thread_id != event_loop_thread for _name, thread_id in call_threads)
assert worker_activity.memory_worker_status().is_running is True
assert [(run.url, run.thread_id, run.run_id) for run in spawned] == [
("http://x", "worker-thread", "run-1")
]
def test_completed_memory_activity_clear_preserves_pending_worker_delta(tmp_path):
memory_dir = tmp_path / "memories"
before = worker_activity.snapshot_memory_outputs(memory_dir)
_mark_worker_started(
memory_dir,
thread_id="finished-thread",
run_id="finished-run",
before_outputs=before,
)
profile_path = memory_dir / "profile" / "USER_PROFILE.md"
profile_path.parent.mkdir(parents=True)
profile_path.write_text("# User profile\n\n- remembered\n", encoding="utf-8")
worker_activity.mark_memory_worker_finished("finished-thread", "finished-run")
_mark_worker_started(
memory_dir,
thread_id="active-thread",
run_id="active-run",
)
worker_activity.mark_observation_relations_linked(1)
worker_activity.clear_completed_memory_activity_counts()
assert worker_activity.memory_worker_status().is_running is True
assert worker_activity.observation_linker_status().relations_linked == 0
observation_path = memory_dir / "observations" / "global" / "O-1.md"
observation_path.parent.mkdir(parents=True)
observation_path.write_text("# Observation\n", encoding="utf-8")
worker_activity.mark_memory_worker_finished("active-thread", "active-run")
status = worker_activity.memory_worker_status()
assert status.is_running is False
assert status.profile_updates == 0
assert status.observations_recorded == 1
def test_memory_worker_observed_outputs_includes_active_worker_delta(tmp_path):
memory_dir = tmp_path / "memories"
before = worker_activity.snapshot_memory_outputs(memory_dir)
_mark_worker_started(
memory_dir,
thread_id="active-thread",
run_id="active-run",
before_outputs=before,
)
record_observation_file(
memory_dir=memory_dir,
project_id="P-project",
memory_type=MemoryType.SEMANTIC,
summary="Active worker observation.",
observation="The active worker has already written an observation.",
why_it_matters="One-shot CLI waits can detect persisted worker output.",
scope=MemoryScope.PROJECT,
source_type=MemorySourceType.TURN,
source_session_id="thread-1",
source_agent="EvoScientist",
)
status = worker_activity.memory_worker_observed_outputs()
assert status.is_running is True
assert status.observations_recorded == 1
assert status.profile_updates == 0
assert worker_activity.memory_worker_status().observations_recorded == 0
def test_memory_pipeline_wait_keeps_polling_after_observed_memory_output():
now = 0.0
saved_counts = []
observed_calls = 0
def monotonic():
return now
def sleep(seconds):
nonlocal now
now += seconds
def get_worker_status():
nonlocal observed_calls
observed_calls += 1
if observed_calls < 8:
return worker_activity.MemoryWorkerStatusSnapshot(
is_running=True,
observations_recorded=1,
)
return worker_activity.MemoryWorkerStatusSnapshot(
is_running=False,
observations_recorded=1,
profile_updates=1,
)
waited_until_idle = worker_activity.wait_for_memory_pipeline_idle(
timeout_seconds=10,
poll_seconds=0.5,
output_grace_seconds=3,
get_worker_status=get_worker_status,
get_linker_status=worker_activity.ObservationLinkerStatusSnapshot,
monotonic=monotonic,
sleep=sleep,
on_saved=lambda status: saved_counts.append(
(status.observations_recorded, status.profile_updates)
),
)
assert waited_until_idle is True
assert observed_calls == 9
assert saved_counts == [(1, 0), (1, 1)]
def test_memory_pipeline_wait_reports_fast_worker_output():
saved_counts = []
waited_until_idle = worker_activity.wait_for_memory_pipeline_idle(
timeout_seconds=10,
poll_seconds=0.5,
output_grace_seconds=3,
get_worker_status=lambda: worker_activity.MemoryWorkerStatusSnapshot(
is_running=False,
observations_recorded=1,
),
get_linker_status=worker_activity.ObservationLinkerStatusSnapshot,
on_saved=lambda status: saved_counts.append(
(status.observations_recorded, status.profile_updates)
),
)
assert waited_until_idle is True
assert saved_counts == [(1, 0)]
def test_memory_pipeline_waits_for_observation_linker():
now = 0.0
waiting_phases = []
linker_calls = 0
def monotonic():
return now
def sleep(seconds):
nonlocal now
now += seconds
def get_linker_status():
nonlocal linker_calls
linker_calls += 1
return worker_activity.ObservationLinkerStatusSnapshot(
is_running=linker_calls < 3
)
waited_until_idle = worker_activity.wait_for_memory_pipeline_idle(
timeout_seconds=10,
poll_seconds=0.5,
output_grace_seconds=3,
get_worker_status=lambda: worker_activity.MemoryWorkerStatusSnapshot(
is_running=False
),
get_linker_status=get_linker_status,
monotonic=monotonic,
sleep=sleep,
on_waiting=waiting_phases.append,
)
assert waited_until_idle is True
assert linker_calls >= 3
assert waiting_phases == ["linker", "linker"]
def test_memory_pipeline_waits_while_observation_linker_is_launching(tmp_path):
entered_launch = threading.Event()
release_launch = threading.Event()
waiting_phases: list[worker_activity.MemoryActivityPhase] = []
def launch_linker(_context: memory_scheduler.ObservationLinkerContext):
entered_launch.set()
release_launch.wait(timeout=5)
def on_waiting(phase: worker_activity.MemoryActivityPhase) -> None:
waiting_phases.append(phase)
release_launch.set()
coordinator = memory_scheduler.MemoryScheduler(launch_linker=launch_linker)
coordinator.record_observation_created(
_linker_context(
memory_dir=tmp_path / "memories",
workspace_dir=tmp_path / "workspace",
observation_ids=("O-1",),
)
)
flush_thread = threading.Thread(target=coordinator.flush_ready)
flush_thread.start()
try:
assert entered_launch.wait(timeout=1)
waited_until_idle = worker_activity.wait_for_memory_pipeline_idle(
timeout_seconds=1,
poll_seconds=0.01,
output_grace_seconds=0,
on_waiting=on_waiting,
)
assert waited_until_idle is True
assert waiting_phases == ["linker"]
finally:
release_launch.set()
flush_thread.join(timeout=1)
def test_memory_pipeline_wait_gives_linker_its_own_timeout_after_worker():
now = 0.0
timed_out_phases = []
worker_calls = 0
linker_calls = 0
def monotonic():
return now
def sleep(seconds):
nonlocal now
now += seconds
def get_worker_status():
nonlocal worker_calls
worker_calls += 1
return worker_activity.MemoryWorkerStatusSnapshot(is_running=worker_calls < 20)
def get_linker_status():
nonlocal linker_calls
if worker_calls < 20:
return worker_activity.ObservationLinkerStatusSnapshot(is_running=False)
linker_calls += 1
return worker_activity.ObservationLinkerStatusSnapshot(
is_running=linker_calls < 4
)
waited_until_idle = worker_activity.wait_for_memory_pipeline_idle(
timeout_seconds=10,
poll_seconds=0.5,
output_grace_seconds=3,
get_worker_status=get_worker_status,
get_linker_status=get_linker_status,
monotonic=monotonic,
sleep=sleep,
on_timeout=timed_out_phases.append,
)
assert waited_until_idle is True
assert worker_calls >= 20
assert linker_calls >= 4
assert timed_out_phases == []
def test_memory_worker_status_dedupes_overlapping_observation_deltas(tmp_path):
memory_dir = tmp_path / "memories"
before = worker_activity.snapshot_memory_outputs(memory_dir)
_mark_worker_started(
memory_dir,
thread_id="thread-1",
run_id="run-1",
before_outputs=before,
)
_mark_worker_started(
memory_dir,
thread_id="thread-2",
run_id="run-2",
before_outputs=before,
)
observation_path = memory_dir / "observations" / "global" / "O-1.md"
observation_path.parent.mkdir(parents=True)
observation_path.write_text("# Observation\n", encoding="utf-8")
first_delta = worker_activity.mark_memory_worker_finished("thread-1", "run-1")
second_delta = worker_activity.mark_memory_worker_finished("thread-2", "run-2")
status = worker_activity.memory_worker_status()
assert first_delta == worker_activity.MemoryOutputDelta(
memory_dir=memory_dir,
observation_paths=("observations/global/O-1.md",),
)
assert second_delta == worker_activity.MemoryOutputDelta(memory_dir=memory_dir)
assert status.is_running is False
assert status.observations_recorded == 1
def test_memory_output_snapshot_uses_posix_relative_paths(tmp_path):
memory_dir = tmp_path / "memories"
profile_path = memory_dir / "profile" / "USER_PROFILE.md"
profile_path.parent.mkdir(parents=True)
profile_path.write_text("# User profile\n", encoding="utf-8")
observation_path = memory_dir / "observations" / "global" / "O-1.md"
observation_path.parent.mkdir(parents=True)
observation_path.write_text("# Observation\n", encoding="utf-8")
snapshot = worker_activity.snapshot_memory_outputs(memory_dir)
assert set(snapshot.profile_files) == {"profile/USER_PROFILE.md"}
assert snapshot.observation_files == frozenset({"observations/global/O-1.md"})
def test_memory_worker_clear_does_not_recount_already_credited_file(tmp_path):
memory_dir = tmp_path / "memories"
before = worker_activity.snapshot_memory_outputs(memory_dir)
_mark_worker_started(
memory_dir,
thread_id="thread-1",
run_id="run-1",
before_outputs=before,
)
_mark_worker_started(
memory_dir,
thread_id="thread-2",
run_id="run-2",
before_outputs=before,
)
observation_path = memory_dir / "observations" / "global" / "O-1.md"
observation_path.parent.mkdir(parents=True)
observation_path.write_text("# Observation\n", encoding="utf-8")
first_delta = worker_activity.mark_memory_worker_finished("thread-1", "run-1")
assert worker_activity.memory_worker_status().observations_recorded == 1
worker_activity.clear_completed_memory_activity_counts()
second_delta = worker_activity.mark_memory_worker_finished("thread-2", "run-2")
status = worker_activity.memory_worker_status()
assert first_delta == worker_activity.MemoryOutputDelta(
memory_dir=memory_dir,
observation_paths=("observations/global/O-1.md",),
)
assert second_delta == worker_activity.MemoryOutputDelta(memory_dir=memory_dir)
assert status.is_running is False
assert status.observations_recorded == 0
# ──────────────────────────────────────────────────────────────────────
# per-file parse cache for list_observation_documents
# ──────────────────────────────────────────────────────────────────────
def _write_observation(path, obs_id, summary="test", scope="global", related_id=""):
path.parent.mkdir(parents=True, exist_ok=True)
related_block = ""
if related_id:
related_block = (
"related_observations:\n"
f" - id: {related_id}\n"
" relation: complements\n"
f" reason: links to {related_id}\n"
" linked_at: '2026-01-01T00:00:00Z'\n"
)
path.write_text(
"---\n"
f"id: {obs_id}\n"
f"summary: {summary}\n"
"memory_type: procedural\n"
f"scope: {scope}\n"
"source:\n"
" type: turn\n"
" agent: EvoScientist\n"
f"{related_block}"
"---\n"
"Body text.\n",
encoding="utf-8",
)
class TestObservationCache:
"""Cache behaviour tests with automatic cache isolation."""
@pytest.fixture(autouse=True)
def _clear_observation_cache(self):
"""Clear the process-scoped parse cache before and after each test."""
from EvoScientist.memory.observations import store
store._file_parse_cache.clear()
store._cached_max_files = None
yield
store._file_parse_cache.clear()
store._cached_max_files = None
def test_cache_returns_same_documents_without_reparse(self, tmp_path, monkeypatch):
"""A second call with unchanged files must hit the cache, not re-read."""
from EvoScientist.memory.observations import store
memories = tmp_path / "memories"
_write_observation(
memories / "observations" / "global" / "O-1.md", "O-1", "first"
)
parse_calls: list[int] = []
real_parse = store._parse_observation_search_document
def _counting_parse(*args, **kwargs):
parse_calls.append(1)
return real_parse(*args, **kwargs)
monkeypatch.setattr(
store, "_parse_observation_search_document", _counting_parse
)
docs1 = list_observation_documents(memory_dir=memories, project_id="P-test")
assert len(docs1) == 1
assert len(parse_calls) == 1
docs2 = list_observation_documents(memory_dir=memories, project_id="P-test")
assert len(docs2) == 1
assert len(parse_calls) == 1, "second call must hit the cache, not re-parse"
def test_cache_invalidates_on_file_modification(self, tmp_path, monkeypatch):
"""Changing a file's mtime must force a re-read."""
import os
from EvoScientist.memory.observations import store
memories = tmp_path / "memories"
obs_path = memories / "observations" / "global" / "O-1.md"
_write_observation(obs_path, "O-1", "original")
parse_calls: list[int] = []
real_parse = store._parse_observation_search_document
def _counting_parse(*args, **kwargs):
parse_calls.append(1)
return real_parse(*args, **kwargs)
monkeypatch.setattr(
store, "_parse_observation_search_document", _counting_parse
)
docs1 = list_observation_documents(memory_dir=memories, project_id="P-test")
assert docs1[0].summary == "original"
assert len(parse_calls) == 1
# Modify the file (new content + new mtime). Explicit bump because
# Windows NTFS mtime resolution can be coarse enough that a same-tick
# rewrite keeps the old signature and the cache returns stale data.
_write_observation(obs_path, "O-1", "updated")
st = obs_path.stat()
os.utime(obs_path, (st.st_atime, st.st_mtime + 1.0))
docs2 = list_observation_documents(memory_dir=memories, project_id="P-test")
assert docs2[0].summary == "updated"
assert len(parse_calls) == 2, "modified file must invalidate the cache"
def test_cache_adds_new_file_without_reparsing_existing(
self, tmp_path, monkeypatch
):
"""Adding a new file parses only the new file; cached files stay cached."""
from EvoScientist.memory.observations import store
memories = tmp_path / "memories"
_write_observation(memories / "observations" / "global" / "O-1.md", "O-1")
parse_calls: list[int] = []
real_parse = store._parse_observation_search_document
def _counting_parse(*args, **kwargs):
parse_calls.append(1)
return real_parse(*args, **kwargs)
monkeypatch.setattr(
store, "_parse_observation_search_document", _counting_parse
)
docs1 = list_observation_documents(memory_dir=memories, project_id="P-test")
assert len(docs1) == 1
assert len(parse_calls) == 1
_write_observation(memories / "observations" / "global" / "O-2.md", "O-2")
docs2 = list_observation_documents(memory_dir=memories, project_id="P-test")
assert len(docs2) == 2
assert len(parse_calls) == 2, (
"only the new file must be parsed; the existing file stays cached "
"(1 from the first call + 1 new = 2)"
)
def test_cache_deletion_drops_file_without_reparsing(self, tmp_path, monkeypatch):
"""Deleting a file drops it from the results without re-parsing the rest."""
from EvoScientist.memory.observations import store
memories = tmp_path / "memories"
obs_a = memories / "observations" / "global" / "O-1.md"
obs_b = memories / "observations" / "global" / "O-2.md"
_write_observation(obs_a, "O-1")
_write_observation(obs_b, "O-2")
parse_calls: list[int] = []
real_parse = store._parse_observation_search_document
def _counting_parse(*args, **kwargs):
parse_calls.append(1)
return real_parse(*args, **kwargs)
monkeypatch.setattr(
store, "_parse_observation_search_document", _counting_parse
)
docs1 = list_observation_documents(memory_dir=memories, project_id="P-test")
assert len(docs1) == 2
assert len(parse_calls) == 2
obs_a.unlink()
docs2 = list_observation_documents(memory_dir=memories, project_id="P-test")
assert len(docs2) == 1
assert docs2[0].observation_id == "O-2"
assert len(parse_calls) == 2, (
"the deleted file must simply drop out of the glob and the "
"surviving file must stay cached, so the second call re-parses "
"nothing (2 parses total, both from the first call)"
)
def test_unparsable_file_is_not_cached(self, tmp_path, monkeypatch):
"""An unparsable file is skipped without poisoning the cache.
Parse failures are not cached: the broken file is retried on every
call, while the healthy file stays cached.
"""
from EvoScientist.memory.observations import store
memories = tmp_path / "memories"
global_dir = memories / "observations" / "global"
_write_observation(global_dir / "O-good.md", "O-good")
broken = global_dir / "missing-id.md"
broken.parent.mkdir(parents=True, exist_ok=True)
broken.write_text(
"---\n"
"summary: Missing id so this file is skipped\n"
"memory_type: procedural\n"
"scope: global\n"
"---\n"
"Body\n",
encoding="utf-8",
)
parse_calls: list[int] = []
real_parse = store._parse_observation_search_document
def _counting_parse(*args, **kwargs):
parse_calls.append(1)
return real_parse(*args, **kwargs)
monkeypatch.setattr(
store, "_parse_observation_search_document", _counting_parse
)
docs1 = list_observation_documents(memory_dir=memories, project_id="P-test")
assert [document.observation_id for document in docs1] == ["O-good"]
assert len(parse_calls) == 2
docs2 = list_observation_documents(memory_dir=memories, project_id="P-test")
assert [document.observation_id for document in docs2] == ["O-good"]
assert len(parse_calls) == 3, (
"the healthy file must stay cached while the unparsable file is "
"retried on each call"
)
def test_cache_shares_entries_across_memory_type_filters(
self, tmp_path, monkeypatch
):
"""Calls with different ``memory_type`` filters share one cache entry."""
from EvoScientist.memory.observations import store
memories = tmp_path / "memories"
_write_observation(memories / "observations" / "global" / "O-1.md", "O-1")
parse_calls: list[int] = []
real_parse = store._parse_observation_search_document
def _counting_parse(*args, **kwargs):
parse_calls.append(1)
return real_parse(*args, **kwargs)
monkeypatch.setattr(
store, "_parse_observation_search_document", _counting_parse
)
docs_all = list_observation_documents(memory_dir=memories, project_id="P-test")
assert len(docs_all) == 1
assert len(parse_calls) == 1
docs_filtered = list_observation_documents(
memory_dir=memories, project_id="P-test", memory_type=MemoryType.PROCEDURAL
)
assert len(docs_filtered) == 1
assert len(parse_calls) == 1, (
"different memory_type filter must share the cache entry"
)
def test_cache_shares_global_docs_across_projects(self, tmp_path, monkeypatch):
"""Global observations must be parsed once and shared across project_ids.
The parse cache is keyed on the file path, so a global document is a
single entry regardless of which project reads it. Two projects
sharing the same ``memory_dir`` must not each re-parse the global
store.
"""
from EvoScientist.memory.observations import store
memories = tmp_path / "memories"
_write_observation(
memories / "observations" / "global" / "O-global.md", "O-global"
)
_write_observation(
memories / "observations" / "projects" / "P-A" / "O-a.md",
"O-a",
scope="project",
)
_write_observation(
memories / "observations" / "projects" / "P-B" / "O-b.md",
"O-b",
scope="project",
)
parse_calls: list[int] = []
real_parse = store._parse_observation_search_document
def _counting_parse(*args, **kwargs):
parse_calls.append(1)
return real_parse(*args, **kwargs)
monkeypatch.setattr(
store, "_parse_observation_search_document", _counting_parse
)
# Project A: parses global (1) + project-A (1) = 2
docs_a = list_observation_documents(memory_dir=memories, project_id="P-A")
assert len(docs_a) == 2
assert len(parse_calls) == 2
# Project B: parses project-B (1), global is a cache hit = 1
docs_b = list_observation_documents(memory_dir=memories, project_id="P-B")
assert len(docs_b) == 2
assert len(parse_calls) == 3, (
"global docs must be a cache hit for project B; only the new "
"project-B file should be parsed (2 from A + 1 from B = 3)"
)
def test_lru_evicts_least_recently_used_file(self, tmp_path, monkeypatch):
"""The per-file cache must evict the least-recently-used file past the cap."""
from EvoScientist.memory.observations import store
memories = tmp_path / "memories"
for project in ("P-A", "P-B", "P-C"):
_write_observation(
memories / "observations" / "projects" / project / f"O-{project}.md",
f"O-{project}",
scope="project",
)
parse_calls: list[int] = []
real_parse = store._parse_observation_search_document
def _counting_parse(*args, **kwargs):
parse_calls.append(1)
return real_parse(*args, **kwargs)
monkeypatch.setattr(
store, "_parse_observation_search_document", _counting_parse
)
monkeypatch.setattr(store, "_max_cached_files", lambda: 2)
# Three calls, one file each. cap=2 evicts the LRU file (P-A) at the
# end of the third call, when P-C is added.
for project in ("P-A", "P-B", "P-C"):
list_observation_documents(memory_dir=memories, project_id=project)
assert len(parse_calls) == 3
assert len(store._file_parse_cache) == 2
# P-B and P-C are still cached: re-listing them re-parses nothing.
list_observation_documents(memory_dir=memories, project_id="P-B")
list_observation_documents(memory_dir=memories, project_id="P-C")
assert len(parse_calls) == 3, "cached files must not be re-parsed"
# P-A was evicted: re-listing it must re-parse its file.
list_observation_documents(memory_dir=memories, project_id="P-A")
assert len(parse_calls) == 4, "the evicted file must be re-parsed"
def test_cache_keeps_working_set_above_cap(self, tmp_path, monkeypatch):
"""A call must never evict entries its own working set needs.
With a store bigger than the cap, the end-of-call trim keeps every
touched entry, so the cache temporarily exceeds the cap instead of
thrashing, and a second identical call re-parses nothing.
"""
from EvoScientist.memory.observations import store
memories = tmp_path / "memories"
_write_observation(memories / "observations" / "global" / "O-1.md", "O-1")
_write_observation(memories / "observations" / "global" / "O-2.md", "O-2")
parse_calls: list[int] = []
real_parse = store._parse_observation_search_document
def _counting_parse(*args, **kwargs):
parse_calls.append(1)
return real_parse(*args, **kwargs)
monkeypatch.setattr(
store, "_parse_observation_search_document", _counting_parse
)
monkeypatch.setattr(store, "_max_cached_files", lambda: 1)
docs1 = list_observation_documents(memory_dir=memories, project_id="P-test")
assert len(docs1) == 2
assert len(parse_calls) == 2
# Both files were touched by the call, so both stay despite cap=1.
assert len(store._file_parse_cache) == 2
docs2 = list_observation_documents(memory_dir=memories, project_id="P-test")
assert len(docs2) == 2
assert len(parse_calls) == 2, (
"a second identical call must hit the cache, not thrash and re-parse"
)
def test_fallback_link_resolution_reuses_cached_parses(self, tmp_path, monkeypatch):
"""Link resolution reruns each call but must reuse the memoized parses.
When ``scope=PROJECT``, only project files are parsed up front. A
project observation linking to a global observation triggers the
link-resolution fallback, which walks all files. Because those walks
parse through the per-file cache, a second call re-parses nothing even
though link resolution runs again.
"""
from EvoScientist.memory.observations import store
memories = tmp_path / "memories"
_write_observation(memories / "observations" / "global" / "O-g.md", "O-g")
_write_observation(
memories / "observations" / "projects" / "P-A" / "O-a.md",
"O-a",
scope="project",
related_id="O-g",
)
parse_calls: list[int] = []
real_parse = store._parse_observation_search_document
def _counting_parse(*args, **kwargs):
parse_calls.append(1)
return real_parse(*args, **kwargs)
monkeypatch.setattr(
store, "_parse_observation_search_document", _counting_parse
)
# First call with scope=PROJECT: parses the project file, then the
# link-resolution fallback walks and parses the linked global target.
docs1 = list_observation_documents(
memory_dir=memories, project_id="P-A", scope=MemoryScope.PROJECT
)
first_call_count = len(parse_calls)
assert first_call_count >= 2, (
"first call must parse the project file and the fallback must "
"parse the linked global observation"
)
# Second call: link resolution reruns, but every parse is a cache hit.
docs2 = list_observation_documents(
memory_dir=memories, project_id="P-A", scope=MemoryScope.PROJECT
)
assert len(parse_calls) == first_call_count, (
"second call must reuse the memoized parses; re-parsing means the "
"fallback bypasses the per-file cache"
)
assert len(docs1) == len(docs2)
def test_concurrent_calls_survive_cache_eviction(self, tmp_path, monkeypatch):
"""Concurrent callers must not race the eviction path.
A tiny cap forces ``_trim_parse_cache`` to evict on every call while
several threads hit ``list_observation_documents`` at once. Without
serialized cache transactions, one thread can evict a key between
another thread's lookup and recency update (KeyError), or between its
insert and recency update. The switch interval is shrunk so the GIL
switches threads inside those windows often enough to make the race
reproducible; it is restored before the test exits.
The hammer is deadline-bounded rather than iteration-bounded: a fixed
iteration count overran the 30s CI per-test timeout on Windows runners
(context switches and file I/O are much slower there), while on fast
runners a fixed count needlessly capped race coverage. A deadline
finishes within budget on every platform and still maximizes race
opportunities where threads are cheap.
All workers park at a ``threading.Barrier`` and the deadline is armed
in the barrier's action, so the clock starts only when every worker is
ready and thread startup cannot consume the window. Each worker counts
its iterations and the test asserts all of them made progress, so it
cannot pass vacuously if a worker never ran.
"""
import sys
import threading
import time
from EvoScientist.memory.observations import store
memories_a = tmp_path / "memories-a"
memories_b = tmp_path / "memories-b"
for memories in (memories_a, memories_b):
for index in range(1, 9):
_write_observation(
memories / "observations" / "global" / f"O-{index}.md",
f"O-{index}",
)
monkeypatch.setattr(store, "_max_cached_files", lambda: 1)
errors: list[BaseException] = []
memory_dirs = [
memories_a,
memories_a,
memories_a,
memories_b,
memories_b,
memories_b,
]
iterations = [0] * len(memory_dirs)
deadline = 0.0
def _start_clock():
# The barrier runs this once, immediately before releasing every
# party, so the deadline begins only when all six workers are ready
# to hammer - thread startup cannot eat any worker's window.
nonlocal deadline
deadline = time.monotonic() + 8.0
gate = threading.Barrier(len(memory_dirs) + 1, action=_start_clock)
def _hammer(memory_dir, index):
gate.wait()
try:
while time.monotonic() < deadline and not errors:
docs = list_observation_documents(
memory_dir=memory_dir, project_id="P-test"
)
assert len(docs) == 8
iterations[index] += 1
except BaseException as exc:
errors.append(exc)
threads = [
threading.Thread(target=_hammer, args=(memory_dir, index))
for index, memory_dir in enumerate(memory_dirs)
]
old_interval = sys.getswitchinterval()
sys.setswitchinterval(1e-6)
try:
for thread in threads:
thread.start()
# Wait for all workers to reach the gate, then release them together.
gate.wait()
for thread in threads:
thread.join()
finally:
sys.setswitchinterval(old_interval)
assert errors == [], f"racing cache transactions raised: {errors[:3]}"
assert all(count > 0 for count in iterations), (
f"every worker must make progress, got iterations={iterations}"
)