chore: initialize EverOS 1.0.0

md-first memory extraction framework for AI agents.

Markdown is the single source of truth; SQLite holds state and LanceDB
provides the rebuildable vector + BM25 + scalar index. The codebase follows
a single-direction DDD layering (entrypoints -> service -> memory -> infra,
with component / core / config cross-cutting) enforced by import-linter.

Engineering surface:
- Coding conventions in .claude/rules/ (path-scoped) and workflows in
  .claude/skills/ (/commit, /new-branch, /pr).
- GitHub Actions CI runs make lint + test + integration; pre-commit mirrors
  the gates locally (ruff, hygiene hooks, gitlint commit-msg).
- Commit messages follow Conventional Commits, enforced by gitlint.
- make lint also enforces datetime two-zone discipline and OpenAPI drift.
This commit is contained in:
Elliot Chen
2026-06-05 22:35:51 +08:00
commit 518b8eca85
636 changed files with 160553 additions and 0 deletions

View File

@ -0,0 +1,323 @@
from __future__ import annotations
import datetime as _dt
import importlib
import uuid
from unittest.mock import AsyncMock, patch
import pytest
import structlog.testing
from everalgo.types import AgentCase, ChatMessage, MemCell
from everos.core.persistence import EntryId
from everos.infra.ome.testing import FakeStrategyContext
from everos.memory.events import AgentCaseExtracted, AgentPipelineStarted
from everos.memory.strategies.extract_agent_case import extract_agent_case
def _fake_eid() -> EntryId:
return EntryId(prefix="ac", date=_dt.date(2026, 5, 17), seq=1)
mod = importlib.import_module("everos.memory.strategies.extract_agent_case")
def _agent_memcell() -> MemCell:
return MemCell(
items=[
ChatMessage(
id="m1",
role="user",
content="please summarise the doc",
timestamp=1_700_000_000_000,
sender_id="u_alice",
),
ChatMessage(
id="m2",
role="assistant",
content="here's the summary ...",
timestamp=1_700_000_001_000,
sender_id="agent_42",
),
],
timestamp=1_700_000_001_000,
)
def _event() -> AgentPipelineStarted:
return AgentPipelineStarted(
memcell_id="mc_a", session_id="s1", memcell=_agent_memcell()
)
def _algo_case(
*,
task_intent: str = "summarise doc",
approach: str = "read + condense",
quality_score: float = 0.8,
key_insight: str = "",
) -> AgentCase:
return AgentCase(
id=uuid.uuid4().hex,
timestamp=1_700_000_001_000,
task_intent=task_intent,
approach=approach,
quality_score=quality_score,
key_insight=key_insight,
)
async def test_strategy_meta_is_attached() -> None:
meta = extract_agent_case._ome_strategy_meta # type: ignore[attr-defined]
assert meta.name == "extract_agent_case"
assert AgentPipelineStarted in meta.trigger.on
assert meta.emits == frozenset({AgentCaseExtracted})
assert meta.max_retries == 2
async def test_writes_md_when_algo_returns_a_case(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(mod, "_writer", None, raising=False)
case = _algo_case(quality_score=0.9, key_insight="batch-then-summarise")
with (
patch(
"everos.memory.strategies.extract_agent_case.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.extract_agent_case.AgentCaseExtractor"
) as mock_cls,
patch(
"everos.memory.strategies.extract_agent_case.AgentCaseWriter"
) as mock_wcls,
structlog.testing.capture_logs() as captured,
):
mock_cls.return_value.aextract = AsyncMock(return_value=[case])
mock_wcls.return_value.append_entry = AsyncMock(return_value=_fake_eid())
ctx = FakeStrategyContext()
await extract_agent_case(_event(), ctx)
assert mock_cls.return_value.aextract.await_count == 1
assert mock_wcls.return_value.append_entry.call_count == 1
_, kwargs = mock_wcls.return_value.append_entry.call_args
assert kwargs["inline"]["owner_id"] == "agent_42"
assert kwargs["inline"]["session_id"] == "s1"
assert kwargs["inline"]["parent_type"] == "memcell"
assert kwargs["inline"]["parent_id"] == "mc_a"
assert kwargs["inline"]["quality_score"] == 0.9
assert kwargs["sections"] == {
"TaskIntent": "summarise doc",
"Approach": "read + condense",
"KeyInsight": "batch-then-summarise",
}
# Chain emit: AgentCaseExtracted fires after the md write.
emitted = [e for e in ctx.emitted if isinstance(e, AgentCaseExtracted)]
assert len(emitted) == 1
assert emitted[0].memcell_id == "mc_a"
assert emitted[0].case_entry_id == _fake_eid().format()
assert emitted[0].task_intent == "summarise doc"
assert emitted[0].quality_score == 0.9
assert emitted[0].case_timestamp_ms == 1_700_000_001_000
assert emitted[0].agent_id == "agent_42"
matching = [e for e in captured if e.get("event") == "agent_case_extracted"]
assert matching, "expected agent_case_extracted log line"
assert matching[0]["owner_ids"] == ["agent_42"]
assert matching[0]["fanout"] == 1
assert matching[0]["quality_score"] == 0.9
async def test_fans_out_per_assistant_sender(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""One LLM call, then md write + emit per distinct assistant sender.
Case text is third-person (``the agent did X``) so the same body
is a valid reference experience for every assistant sender that
participated in the trajectory. Verifies: aextract is called
exactly once, md is written once per agent, and an
``AgentCaseExtracted`` event fires per agent so the downstream
skill clustering chain runs in each agent's own scope.
"""
monkeypatch.setattr(mod, "_writer", None, raising=False)
multi_agent_cell = MemCell(
items=[
ChatMessage(
id="m1",
role="user",
content="please dispatch",
timestamp=1_700_000_000_000,
sender_id="u_alice",
),
ChatMessage(
id="m2",
role="assistant",
content="dispatching to specialist",
timestamp=1_700_000_001_000,
sender_id="agent_lead",
),
ChatMessage(
id="m3",
role="assistant",
content="here is the answer",
timestamp=1_700_000_002_000,
sender_id="agent_specialist",
),
],
timestamp=1_700_000_002_000,
)
event = AgentPipelineStarted(
memcell_id="mc_multi", session_id="s_multi", memcell=multi_agent_cell
)
case = _algo_case(quality_score=0.85)
# writer.append_entry returns a different entry_id per call so the
# emitted events carry per-agent entry_ids (cascade keys off owner+entry).
eids = [
EntryId(prefix="ac", date=_dt.date(2026, 5, 17), seq=1),
EntryId(prefix="ac", date=_dt.date(2026, 5, 17), seq=2),
]
with (
patch(
"everos.memory.strategies.extract_agent_case.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.extract_agent_case.AgentCaseExtractor"
) as mock_cls,
patch(
"everos.memory.strategies.extract_agent_case.AgentCaseWriter"
) as mock_wcls,
structlog.testing.capture_logs() as captured,
):
mock_cls.return_value.aextract = AsyncMock(return_value=[case])
mock_wcls.return_value.append_entry = AsyncMock(side_effect=eids)
ctx = FakeStrategyContext()
await extract_agent_case(event, ctx)
# Exactly one LLM call regardless of agent count.
assert mock_cls.return_value.aextract.await_count == 1
# Two md writes (one per distinct assistant sender), in first-seen order.
assert mock_wcls.return_value.append_entry.call_count == 2
owners_written = [
call.kwargs["inline"]["owner_id"]
for call in mock_wcls.return_value.append_entry.call_args_list
]
assert owners_written == ["agent_lead", "agent_specialist"]
# Two emits, each tagged with its own agent_id + per-agent entry_id.
emitted = [e for e in ctx.emitted if isinstance(e, AgentCaseExtracted)]
assert len(emitted) == 2
assert [e.agent_id for e in emitted] == ["agent_lead", "agent_specialist"]
assert [e.case_entry_id for e in emitted] == [eids[0].format(), eids[1].format()]
# Same task body / quality across the fan-out (broadcast semantics).
assert {e.task_intent for e in emitted} == {"summarise doc"}
assert {e.quality_score for e in emitted} == {0.85}
matching = [e for e in captured if e.get("event") == "agent_case_extracted"]
assert matching, "expected agent_case_extracted log line"
assert matching[0]["owner_ids"] == ["agent_lead", "agent_specialist"]
assert matching[0]["fanout"] == 2
async def test_omits_key_insight_section_when_empty(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(mod, "_writer", None, raising=False)
case = _algo_case(key_insight="")
with (
patch(
"everos.memory.strategies.extract_agent_case.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.extract_agent_case.AgentCaseExtractor"
) as mock_cls,
patch(
"everos.memory.strategies.extract_agent_case.AgentCaseWriter"
) as mock_wcls,
):
mock_cls.return_value.aextract = AsyncMock(return_value=[case])
mock_wcls.return_value.append_entry = AsyncMock(return_value=_fake_eid())
await extract_agent_case(_event(), FakeStrategyContext())
_, kwargs = mock_wcls.return_value.append_entry.call_args
assert "KeyInsight" not in kwargs["sections"]
assert kwargs["sections"]["TaskIntent"] == "summarise doc"
async def test_skips_when_algo_returns_empty(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Algo pre-filter rejected the cell — no md written, log a noop line."""
monkeypatch.setattr(mod, "_writer", None, raising=False)
with (
patch(
"everos.memory.strategies.extract_agent_case.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.extract_agent_case.AgentCaseExtractor"
) as mock_cls,
patch(
"everos.memory.strategies.extract_agent_case.AgentCaseWriter"
) as mock_wcls,
structlog.testing.capture_logs() as captured,
):
mock_cls.return_value.aextract = AsyncMock(return_value=[])
mock_wcls.return_value.append_entry = AsyncMock(return_value=_fake_eid())
await extract_agent_case(_event(), FakeStrategyContext())
mock_wcls.return_value.append_entry.assert_not_called()
matching = [e for e in captured if e.get("event") == "agent_case_skipped_by_algo"]
assert matching, "expected agent_case_skipped_by_algo log line"
async def test_skips_when_no_assistant_sender(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""No assistant in the cell → no agent_id can be inferred; algo not called."""
user_only = MemCell(
items=[
ChatMessage(
id="m1",
role="user",
content="hi",
timestamp=1_700_000_000_000,
sender_id="u_alice",
),
],
timestamp=1_700_000_000_000,
)
event = AgentPipelineStarted(memcell_id="mc_b", session_id="s1", memcell=user_only)
monkeypatch.setattr(mod, "_writer", None, raising=False)
with (
patch(
"everos.memory.strategies.extract_agent_case.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.extract_agent_case.AgentCaseExtractor"
) as mock_cls,
patch(
"everos.memory.strategies.extract_agent_case.AgentCaseWriter"
) as mock_wcls,
structlog.testing.capture_logs() as captured,
):
mock_cls.return_value.aextract = AsyncMock(return_value=[])
mock_wcls.return_value.append_entry = AsyncMock(return_value=_fake_eid())
await extract_agent_case(event, FakeStrategyContext())
# Algo extractor must not be invoked at all when there's no agent.
mock_cls.return_value.aextract.assert_not_called()
mock_wcls.return_value.append_entry.assert_not_called()
matching = [
e for e in captured if e.get("event") == "agent_case_skipped_no_assistant"
]
assert matching, "expected agent_case_skipped_no_assistant log line"

View File

@ -0,0 +1,584 @@
"""Tests for :func:`extract_agent_skill`.
Mocked seams: ``cluster_repo`` (sqlite), ``agent_case_repo`` /
``agent_skill_repo`` (LanceDB), ``get_embedder`` (component),
``AgentSkillExtractor`` (algo), ``AgentSkillWriter`` (md). Each
retry-class exception (cluster missing / case-not-indexed) bubbles up so
OME's ``max_retries`` machinery catches the race instead of the strategy
implementing its own backoff loop.
LanceDB repo behaviour itself (predicate isolation, cosine ranking,
``_distance`` stripping) lives under
``tests/unit/test_infra/test_lancedb/test_repos/``; strategy tests only
verify routing decisions and orchestration glue.
"""
from __future__ import annotations
import asyncio
import datetime as _dt
import importlib
from unittest.mock import AsyncMock, MagicMock, patch
import numpy as np
import pytest
from everalgo.clustering import Cluster as AlgoCluster
from everalgo.types import AgentSkill as AlgoAgentSkill
from everos.component.embedding import (
EmbeddingError,
EmbeddingNotConfiguredError,
)
from everos.infra.ome.testing import FakeStrategyContext
from everos.memory.events import SkillClusterUpdated
from everos.memory.strategies._partition_locks import _reset_for_tests
from everos.memory.strategies.extract_agent_skill import (
MAX_SKILLS_IN_PROMPT,
MAX_SUPPORTING_CASES,
_CaseNotYetIndexedError,
_ClusterMissingError,
_collect_supporting_entry_ids,
_resolve_query_vector,
_select_existing_skills,
_select_supporting_cases,
extract_agent_skill,
)
@pytest.fixture(autouse=True)
def _isolate_partition_locks() -> None:
_reset_for_tests()
def _event(
*,
cluster_id: str = "cl_xxxxxxxxxxx1",
case_entry_id: str = "ac_20260517_0001",
agent_id: str = "agent_42",
) -> SkillClusterUpdated:
return SkillClusterUpdated(
case_entry_id=case_entry_id,
cluster_id=cluster_id,
agent_id=agent_id,
)
def _algo_cluster(
*,
cluster_id: str = "cl_xxxxxxxxxxx1",
members: list[str] | None = None,
) -> AlgoCluster:
return AlgoCluster(
id=cluster_id,
centroid=np.zeros(1024, dtype=np.float32),
count=len(members or ["ac_20260517_0001"]),
last_ts=1_700_000_000_000,
preview=[],
members=members or ["ac_20260517_0001"],
)
def _lance_case(
entry_id: str,
*,
quality_score: float = 0.8,
timestamp: _dt.datetime | None = None,
vector: list[float] | None = None,
task_intent: str | None = None,
) -> MagicMock:
"""Stand-in for a LanceDB AgentCase row (only fields the strategy reads)."""
case = MagicMock()
case.entry_id = entry_id
case.timestamp = timestamp or _dt.datetime(2026, 5, 17, tzinfo=_dt.UTC)
case.task_intent = (
task_intent if task_intent is not None else f"intent of {entry_id}"
)
case.approach = f"approach of {entry_id}"
case.quality_score = quality_score
case.key_insight = ""
case.vector = vector or []
return case
def _lance_skill(
*,
name: str = "old_skill",
cluster_id: str = "cl_xxxxxxxxxxx1",
source_case_ids: list[str] | None = None,
) -> MagicMock:
skill = MagicMock()
skill.id = f"agent_42_{name}"
skill.cluster_id = cluster_id
skill.name = name
skill.description = f"desc {name}"
skill.content = f"content {name}"
skill.confidence = 0.5
skill.maturity_score = 0.5
skill.source_case_ids = source_case_ids or []
return skill
def _algo_skill(name: str = "summarise_doc") -> AlgoAgentSkill:
return AlgoAgentSkill(
id="dummyuuid",
cluster_id="", # caller will post-stamp
name=name,
description=f"how to {name}",
content="full body of the skill",
confidence=0.7,
maturity_score=0.5,
source_case_ids=["ac_20260517_0001"],
)
# ── strategy meta + retry-class errors ───────────────────────────────────
async def test_strategy_meta_is_attached() -> None:
meta = extract_agent_skill._ome_strategy_meta # type: ignore[attr-defined]
assert meta.name == "extract_agent_skill"
assert SkillClusterUpdated in meta.trigger.on
assert meta.emits == frozenset()
assert meta.max_retries == 3
async def test_raises_when_cluster_missing_for_retry() -> None:
"""No cluster row yet — OME will retry the run."""
with patch(
"everos.memory.strategies.extract_agent_skill.cluster_repo"
) as mock_repo:
mock_repo.get_with_members = AsyncMock(return_value=None)
with pytest.raises(_ClusterMissingError):
await extract_agent_skill(_event(), FakeStrategyContext())
async def test_raises_when_target_case_not_yet_in_lancedb() -> None:
"""LanceDB has not yet indexed the freshly-written case — let OME retry."""
with (
patch(
"everos.memory.strategies.extract_agent_skill.cluster_repo"
) as mock_cluster_repo,
patch(
"everos.memory.strategies.extract_agent_skill.agent_case_repo"
) as mock_case_repo,
):
mock_cluster_repo.get_with_members = AsyncMock(return_value=_algo_cluster())
mock_case_repo.find_by_owner_entry = AsyncMock(return_value=None)
with pytest.raises(_CaseNotYetIndexedError):
await extract_agent_skill(_event(), FakeStrategyContext())
# ── end-to-end orchestration (mocked) ────────────────────────────────────
@pytest.mark.asyncio
async def test_extracts_and_persists_with_cluster_id_stamped(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""End-to-end (mocked): extractor emits skills → writer stamps cluster_id."""
target = _lance_case("ac_20260517_0001", vector=[0.1] * 1024)
supporting = [_lance_case("ac_20260517_0000")]
existing = [_lance_skill(name="old_skill", source_case_ids=["ac_20260517_0000"])]
emitted = [_algo_skill(name="summarise_doc"), _algo_skill(name="batch_then_synth")]
with (
patch(
"everos.memory.strategies.extract_agent_skill.cluster_repo"
) as mock_cluster_repo,
patch(
"everos.memory.strategies.extract_agent_skill.agent_case_repo"
) as mock_case_repo,
patch(
"everos.memory.strategies.extract_agent_skill.agent_skill_repo"
) as mock_skill_repo,
patch(
"everos.memory.strategies.extract_agent_skill.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.extract_agent_skill.AgentSkillExtractor"
) as mock_extractor_cls,
patch(
"everos.memory.strategies.extract_agent_skill.AgentSkillWriter"
) as mock_writer_cls,
):
mock_cluster_repo.get_with_members = AsyncMock(
return_value=_algo_cluster(members=["ac_20260517_0000", "ac_20260517_0001"])
)
mock_case_repo.find_by_owner_entry = AsyncMock(return_value=target)
mock_case_repo.find_by_owner_entries = AsyncMock(return_value=supporting)
# Small cluster path: count ≤ K → scalar fetch returns existing.
mock_skill_repo.count_in_cluster = AsyncMock(return_value=len(existing))
mock_skill_repo.find_in_cluster = AsyncMock(return_value=existing)
mock_extractor_cls.return_value.aextract = AsyncMock(return_value=emitted)
mock_writer_cls.return_value.write_main = AsyncMock(return_value=None)
mod = importlib.import_module("everos.memory.strategies.extract_agent_skill")
monkeypatch.setattr(mod, "_writer", None, raising=False)
await extract_agent_skill(_event(), FakeStrategyContext())
extractor_call = mock_extractor_cls.return_value.aextract.call_args
target_arg = extractor_call.args[0]
assert target_arg.id == "ac_20260517_0001"
assert target_arg.task_intent == "intent of ac_20260517_0001"
assert [s.name for s in extractor_call.kwargs["existing_relevant_skills"]] == [
"old_skill"
]
assert [c.id for c in extractor_call.kwargs["supporting_cases"]] == [
"ac_20260517_0000"
]
write_calls = mock_writer_cls.return_value.write_main.call_args_list
assert len(write_calls) == 2
for call, expected in zip(write_calls, emitted, strict=True):
agent_id_arg, skill_name_arg = call.args
fm = call.kwargs["frontmatter"]
assert agent_id_arg == "agent_42"
assert skill_name_arg == expected.name
assert fm.cluster_id == "cl_xxxxxxxxxxx1"
assert fm.name == expected.name
assert fm.confidence == expected.confidence
assert call.kwargs["body"] == expected.content
# ── _select_existing_skills routing (cluster size × vector availability) ─
async def test_select_existing_skills_small_cluster_uses_scalar_fetch() -> None:
"""``total ≤ K`` short-circuits — no ranking needed for fully-inclusive set."""
target = _lance_case("ac_001", vector=[0.5] * 1024)
skills = [_lance_skill(name=f"s{i}") for i in range(3)]
with patch(
"everos.memory.strategies.extract_agent_skill.agent_skill_repo"
) as mock_repo:
mock_repo.count_in_cluster = AsyncMock(return_value=3)
mock_repo.find_in_cluster = AsyncMock(return_value=skills)
mock_repo.find_topk_relevant_in_cluster = AsyncMock()
got = await _select_existing_skills(
agent_id="a", cluster_id="cl_x", target=target
)
assert got == skills
mock_repo.find_topk_relevant_in_cluster.assert_not_awaited()
mock_repo.find_in_cluster.assert_awaited_once_with(
owner_id="a", cluster_id="cl_x", limit=MAX_SKILLS_IN_PROMPT
)
async def test_select_existing_skills_large_cluster_with_vector_uses_topk() -> None:
"""``total > K`` and target carries vector → cosine top-K path."""
target = _lance_case("ac_001", vector=[0.5] * 1024)
topk_skills = [_lance_skill(name=f"s{i}") for i in range(MAX_SKILLS_IN_PROMPT)]
with patch(
"everos.memory.strategies.extract_agent_skill.agent_skill_repo"
) as mock_repo:
mock_repo.count_in_cluster = AsyncMock(return_value=MAX_SKILLS_IN_PROMPT + 5)
mock_repo.find_topk_relevant_in_cluster = AsyncMock(return_value=topk_skills)
mock_repo.find_in_cluster = AsyncMock()
got = await _select_existing_skills(
agent_id="a", cluster_id="cl_x", target=target
)
assert got == topk_skills
mock_repo.find_in_cluster.assert_not_awaited()
call_kwargs = mock_repo.find_topk_relevant_in_cluster.await_args.kwargs
assert call_kwargs["query_vector"] == [0.5] * 1024
assert call_kwargs["top_k"] == MAX_SKILLS_IN_PROMPT
async def test_select_existing_skills_large_cluster_recomputes_embedding() -> None:
"""``total > K`` but case has no vector → re-embed ``task_intent`` on the fly."""
target = _lance_case("ac_001", vector=[], task_intent="how to summarise docs")
topk_skills = [_lance_skill(name=f"s{i}") for i in range(MAX_SKILLS_IN_PROMPT)]
fresh_vec = [0.42] * 1024
mock_embedder = MagicMock()
mock_embedder.embed = AsyncMock(return_value=fresh_vec)
with (
patch(
"everos.memory.strategies.extract_agent_skill.agent_skill_repo"
) as mock_repo,
patch(
"everos.memory.strategies.extract_agent_skill.get_embedder",
return_value=mock_embedder,
),
):
mock_repo.count_in_cluster = AsyncMock(return_value=MAX_SKILLS_IN_PROMPT + 5)
mock_repo.find_topk_relevant_in_cluster = AsyncMock(return_value=topk_skills)
mock_repo.find_in_cluster = AsyncMock()
got = await _select_existing_skills(
agent_id="a", cluster_id="cl_x", target=target
)
assert got == topk_skills
mock_embedder.embed.assert_awaited_once_with("how to summarise docs")
call_kwargs = mock_repo.find_topk_relevant_in_cluster.await_args.kwargs
assert call_kwargs["query_vector"] == fresh_vec
async def test_select_existing_skills_falls_back_to_scalar_when_embed_fails() -> None:
"""``total > K`` + no vector + embedder fails → scalar fetch capped at K."""
target = _lance_case("ac_001", vector=[], task_intent="how to summarise docs")
scalar_skills = [_lance_skill(name=f"s{i}") for i in range(MAX_SKILLS_IN_PROMPT)]
mock_embedder = MagicMock()
mock_embedder.embed = AsyncMock(side_effect=EmbeddingError("provider down"))
with (
patch(
"everos.memory.strategies.extract_agent_skill.agent_skill_repo"
) as mock_repo,
patch(
"everos.memory.strategies.extract_agent_skill.get_embedder",
return_value=mock_embedder,
),
):
mock_repo.count_in_cluster = AsyncMock(return_value=MAX_SKILLS_IN_PROMPT + 5)
mock_repo.find_in_cluster = AsyncMock(return_value=scalar_skills)
mock_repo.find_topk_relevant_in_cluster = AsyncMock()
got = await _select_existing_skills(
agent_id="a", cluster_id="cl_x", target=target
)
assert got == scalar_skills
mock_repo.find_topk_relevant_in_cluster.assert_not_awaited()
mock_repo.find_in_cluster.assert_awaited_once_with(
owner_id="a", cluster_id="cl_x", limit=MAX_SKILLS_IN_PROMPT
)
# ── _resolve_query_vector layered fallback ───────────────────────────────
async def test_resolve_query_vector_prefers_persisted_vector() -> None:
"""When ``target.vector`` is set, reuse it; never call the embedder."""
target = _lance_case("ac_001", vector=[0.3] * 1024)
with patch(
"everos.memory.strategies.extract_agent_skill.get_embedder"
) as mock_get_embedder:
got = await _resolve_query_vector(target)
assert got == [0.3] * 1024
mock_get_embedder.assert_not_called()
async def test_resolve_query_vector_returns_empty_when_no_text_either() -> None:
"""No persisted vector + no task_intent → ``[]`` (no policy here)."""
target = _lance_case("ac_001", vector=[], task_intent="")
with patch(
"everos.memory.strategies.extract_agent_skill.get_embedder"
) as mock_get_embedder:
got = await _resolve_query_vector(target)
assert got == []
mock_get_embedder.assert_not_called()
async def test_resolve_query_vector_swallows_embedder_not_configured() -> None:
"""Missing embedder config is a deployment issue, not a strategy fault."""
target = _lance_case("ac_001", vector=[], task_intent="hello")
mock_embedder = MagicMock()
mock_embedder.embed = AsyncMock(
side_effect=EmbeddingNotConfiguredError("no api key")
)
with patch(
"everos.memory.strategies.extract_agent_skill.get_embedder",
return_value=mock_embedder,
):
got = await _resolve_query_vector(target)
assert got == []
# ── _select_supporting_cases ranking + cap ───────────────────────────────
async def test_select_supporting_cases_ranks_by_quality_then_timestamp() -> None:
"""Hydrated cases sort ``(quality_score desc, timestamp desc)``."""
skills = [
_lance_skill(name="s1", source_case_ids=["ac_a", "ac_b", "ac_c"]),
]
case_a = _lance_case(
"ac_a",
quality_score=0.4,
timestamp=_dt.datetime(2026, 5, 1, tzinfo=_dt.UTC),
)
case_b = _lance_case(
"ac_b",
quality_score=0.9,
timestamp=_dt.datetime(2026, 5, 1, tzinfo=_dt.UTC),
)
case_c = _lance_case(
"ac_c",
quality_score=0.9,
timestamp=_dt.datetime(2026, 5, 10, tzinfo=_dt.UTC),
)
with patch(
"everos.memory.strategies.extract_agent_skill.agent_case_repo"
) as mock_case_repo:
# Order intentionally scrambled to prove the strategy sorts.
mock_case_repo.find_by_owner_entries = AsyncMock(
return_value=[case_a, case_b, case_c]
)
got = await _select_supporting_cases(
skills,
agent_id="a",
exclude_entry_id="ac_target",
app_id="default",
project_id="default",
)
assert [c.entry_id for c in got] == ["ac_c", "ac_b", "ac_a"]
async def test_select_supporting_cases_caps_at_max_supporting() -> None:
"""Hydrated set is truncated to ``MAX_SUPPORTING_CASES``."""
ids = [f"ac_{i:03d}" for i in range(MAX_SUPPORTING_CASES + 3)]
skills = [_lance_skill(name="s1", source_case_ids=ids)]
hydrated = [
_lance_case(eid, quality_score=0.5 + 0.01 * i) for i, eid in enumerate(ids)
]
with patch(
"everos.memory.strategies.extract_agent_skill.agent_case_repo"
) as mock_case_repo:
mock_case_repo.find_by_owner_entries = AsyncMock(return_value=hydrated)
got = await _select_supporting_cases(
skills,
agent_id="a",
exclude_entry_id="ac_target",
app_id="default",
project_id="default",
)
assert len(got) == MAX_SUPPORTING_CASES
async def test_select_supporting_cases_skips_repo_when_no_lineage_ids() -> None:
"""No usable source ids → ``[]`` without a repo round trip."""
skills = [_lance_skill(name="s1", source_case_ids=[])]
with patch(
"everos.memory.strategies.extract_agent_skill.agent_case_repo"
) as mock_case_repo:
mock_case_repo.find_by_owner_entries = AsyncMock()
got = await _select_supporting_cases(
skills,
agent_id="a",
exclude_entry_id="ac_target",
app_id="default",
project_id="default",
)
assert got == []
mock_case_repo.find_by_owner_entries.assert_not_awaited()
# ── _collect_supporting_entry_ids dedup + exclude ────────────────────────
def test_collect_supporting_entry_ids_dedups_and_excludes_target() -> None:
"""Source ids fold across skills; duplicates and the target id drop out."""
skill_a = MagicMock()
skill_a.source_case_ids = ["ac_a", "ac_b", "ac_target"]
skill_b = MagicMock()
skill_b.source_case_ids = ["ac_b", "ac_c"] # ac_b duplicates skill_a's lineage
skill_empty = MagicMock()
skill_empty.source_case_ids = []
got = _collect_supporting_entry_ids(
[skill_a, skill_b, skill_empty], exclude="ac_target"
)
assert got == ["ac_a", "ac_b", "ac_c"]
def test_collect_supporting_entry_ids_handles_empty_input() -> None:
"""No skills → no supporting cases."""
assert _collect_supporting_entry_ids([], exclude="ac_anything") == []
# ── partition lock (agent_id-level serialisation) ────────────────────────
async def _run_serialisation_probe(
agent_id_run_a: str, agent_id_run_b: str
) -> list[str]:
"""Drive two extract_agent_skill runs and record their critical-section order.
Mocks every I/O seam so the only async work inside the locked region
is a tiny ``asyncio.sleep`` masquerading as the LLM call. The returned
log is the strict enter/leave sequence both runs go through.
"""
log: list[str] = []
async def mock_aextract(case, **_kwargs):
log.append(f"enter:{case.id}")
await asyncio.sleep(0.01)
log.append(f"leave:{case.id}")
return []
target_a = _lance_case("ac_run_a", vector=[0.1] * 1024)
target_b = _lance_case("ac_run_b", vector=[0.1] * 1024)
with (
patch(
"everos.memory.strategies.extract_agent_skill.cluster_repo"
) as mock_cluster_repo,
patch(
"everos.memory.strategies.extract_agent_skill.agent_case_repo"
) as mock_case_repo,
patch(
"everos.memory.strategies.extract_agent_skill.agent_skill_repo"
) as mock_skill_repo,
patch(
"everos.memory.strategies.extract_agent_skill.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.extract_agent_skill.AgentSkillExtractor"
) as mock_extractor_cls,
patch("everos.memory.strategies.extract_agent_skill.AgentSkillWriter"),
):
mock_cluster_repo.get_with_members = AsyncMock(
return_value=_algo_cluster(members=["ac_run_a", "ac_run_b"])
)
mock_case_repo.find_by_owner_entry = AsyncMock(
side_effect=lambda owner, entry, **_kw: (
target_a if entry == "ac_run_a" else target_b
)
)
mock_case_repo.find_by_owner_entries = AsyncMock(return_value=[])
mock_skill_repo.count_in_cluster = AsyncMock(return_value=0)
mock_skill_repo.find_in_cluster = AsyncMock(return_value=[])
mock_extractor_cls.return_value.aextract = mock_aextract
await asyncio.gather(
extract_agent_skill(
_event(agent_id=agent_id_run_a, case_entry_id="ac_run_a"),
FakeStrategyContext(),
),
extract_agent_skill(
_event(agent_id=agent_id_run_b, case_entry_id="ac_run_b"),
FakeStrategyContext(),
),
)
return log
async def test_partition_lock_serialises_runs_on_same_agent() -> None:
"""Two runs sharing ``agent_id`` must not overlap critical sections."""
log = await _run_serialisation_probe("agent_42", "agent_42")
assert log in (
["enter:ac_run_a", "leave:ac_run_a", "enter:ac_run_b", "leave:ac_run_b"],
["enter:ac_run_b", "leave:ac_run_b", "enter:ac_run_a", "leave:ac_run_a"],
)
async def test_partition_lock_lets_different_agents_run_in_parallel() -> None:
"""Runs on distinct ``agent_id`` must overlap (no false serialisation)."""
log = await _run_serialisation_probe("agent_42", "agent_43")
assert log.index("enter:ac_run_a") < log.index("leave:ac_run_b")
assert log.index("enter:ac_run_b") < log.index("leave:ac_run_a")

View File

@ -0,0 +1,223 @@
from __future__ import annotations
import importlib
from unittest.mock import AsyncMock, patch
import pytest
import structlog.testing
from everalgo.types import AtomicFact, ChatMessage, MemCell
from everos.infra.ome.testing import FakeStrategyContext
from everos.memory.events import UserPipelineStarted
from everos.memory.strategies.extract_atomic_facts import extract_atomic_facts
mod = importlib.import_module("everos.memory.strategies.extract_atomic_facts")
def _two_user_memcell() -> MemCell:
return MemCell(
items=[
ChatMessage(
id="m1",
role="user",
content="hi from alice",
timestamp=1_700_000_000_000,
sender_id="u_alice",
),
ChatMessage(
id="m2",
role="user",
content="hi from bob",
timestamp=1_700_000_001_000,
sender_id="u_bob",
),
ChatMessage(
id="m3",
role="assistant",
content="hello both",
timestamp=1_700_000_002_000,
sender_id="agent",
),
],
timestamp=1_700_000_002_000,
)
def _fact(owner_id: str | None, text: str) -> AtomicFact:
return AtomicFact(owner_id=owner_id, content=text, timestamp=1_700_000_000_000)
def _event() -> UserPipelineStarted:
return UserPipelineStarted(
memcell_id="mc_a", session_id="s1", memcell=_two_user_memcell()
)
async def test_strategy_meta_is_attached() -> None:
meta = extract_atomic_facts._ome_strategy_meta # type: ignore[attr-defined]
assert meta.name == "extract_atomic_facts"
assert UserPipelineStarted in meta.trigger.on
assert meta.emits == frozenset()
assert meta.max_retries == 2
async def test_extracts_once_and_fans_out_per_sender(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""One LLM call per memcell; same fact list re-written under each sender.
The algo prompt is subject-agnostic (only ``INPUT_TEXT`` + ``TIME``
placeholders), so re-running it per sender would burn LLM tokens
and let non-determinism drift the per-sender md files apart. The
strategy calls ``aextract`` once with ``sender_id=None`` and
broadcasts the resulting list — every user sender gets its own md
entries pointing at the same fact bodies.
Per-owner batching: the strategy collects each sender's full fact
list and issues one :meth:`append_entries` per owner (not N single
appends), so the call shape is one batch call per sender.
"""
monkeypatch.setattr(mod, "_writer", None, raising=False)
generic_facts = [
_fact(None, "alice mentioned a weekend trip to tokyo"),
_fact(None, "bob said he needs hiking gear"),
]
with (
patch(
"everos.memory.strategies.extract_atomic_facts.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.extract_atomic_facts.AtomicFactExtractor"
) as mock_cls,
patch(
"everos.memory.strategies.extract_atomic_facts.AtomicFactWriter"
) as mock_wcls,
structlog.testing.capture_logs() as captured,
):
mock_cls.return_value.aextract = AsyncMock(return_value=generic_facts)
mock_wcls.return_value.append_entries = AsyncMock(return_value=[])
await extract_atomic_facts(_event(), FakeStrategyContext())
# Exactly one LLM call, parameterised with sender_id=None.
assert mock_cls.return_value.aextract.await_count == 1
call = mock_cls.return_value.aextract.call_args
assert call.kwargs["sender_id"] is None
# 2 senders → 2 batch calls; each batch carries this sender's 2 facts
# (same generic body re-used).
assert mock_wcls.return_value.append_entries.call_count == 2
batch_calls = mock_wcls.return_value.append_entries.call_args_list
batched_owners = sorted(c.args[0] for c in batch_calls)
assert batched_owners == ["u_alice", "u_bob"]
# Flatten items across batches: (owner, fact_text) pairs.
flat = sorted(
(c.args[0], sections["Fact"])
for c in batch_calls
for inline, sections in c.args[1]
)
assert flat == [
("u_alice", "alice mentioned a weekend trip to tokyo"),
("u_alice", "bob said he needs hiking gear"),
("u_bob", "alice mentioned a weekend trip to tokyo"),
("u_bob", "bob said he needs hiking gear"),
]
matching = [e for e in captured if e.get("event") == "atomic_facts_extracted"]
assert matching, "expected atomic_facts_extracted log line"
record = matching[0]
assert record["count"] == 4
assert sorted(record["owner_ids"]) == ["u_alice", "u_bob"]
async def test_writes_md_for_each_fact(
monkeypatch: pytest.MonkeyPatch,
) -> None:
facts = [
_fact("u_alice", "alice likes hiking"),
_fact("u_alice", "alice lives in tokyo"),
]
monkeypatch.setattr(mod, "_writer", None, raising=False)
with (
patch(
"everos.memory.strategies.extract_atomic_facts.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.extract_atomic_facts.AtomicFactExtractor"
) as mock_cls,
patch(
"everos.memory.strategies.extract_atomic_facts.AtomicFactWriter"
) as mock_wcls,
):
mock_cls.return_value.aextract = AsyncMock(return_value=facts)
mock_wcls.return_value.append_entries = AsyncMock(return_value=[])
event = UserPipelineStarted(
memcell_id="mc_a",
session_id="s1",
memcell=MemCell(
items=[
ChatMessage(
id="m1",
role="user",
content="hi",
timestamp=1_700_000_000_000,
sender_id="u_alice",
)
],
timestamp=1_700_000_000_000,
),
)
await extract_atomic_facts(event, FakeStrategyContext())
# Single sender (u_alice) → one batch call with 2 items.
assert mock_wcls.return_value.append_entries.call_count == 1
batch_call = mock_wcls.return_value.append_entries.call_args
assert batch_call.args[0] == "u_alice"
items = batch_call.args[1]
assert len(items) == 2
for (inline, sections), fact in zip(items, facts, strict=True):
assert inline["owner_id"] == "u_alice"
assert inline["session_id"] == "s1"
assert inline["parent_type"] == "memcell"
assert inline["parent_id"] == "mc_a"
assert "sender_ids" not in inline
assert sections == {"Fact": fact.content}
async def test_skips_when_memcell_has_no_messages(
monkeypatch: pytest.MonkeyPatch,
) -> None:
event = UserPipelineStarted(
memcell_id="mc_b",
session_id="s1",
memcell=MemCell(items=[], timestamp=1_700_000_000_000),
)
monkeypatch.setattr(mod, "_writer", None, raising=False)
with (
patch(
"everos.memory.strategies.extract_atomic_facts.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.extract_atomic_facts.AtomicFactExtractor"
) as mock_cls,
patch(
"everos.memory.strategies.extract_atomic_facts.AtomicFactWriter"
) as mock_wcls,
structlog.testing.capture_logs() as captured,
):
mock_cls.return_value.aextract = AsyncMock(return_value=[])
mock_wcls.return_value.append_entries = AsyncMock(return_value=[])
ctx = FakeStrategyContext()
await extract_atomic_facts(event, ctx)
matching = [e for e in captured if e.get("event") == "atomic_facts_extracted"]
assert matching, "log line should still fire (count=0)"
assert matching[0]["count"] == 0
mock_wcls.return_value.append_entries.assert_not_called()

View File

@ -0,0 +1,231 @@
from __future__ import annotations
import importlib
from unittest.mock import AsyncMock, patch
import pytest
import structlog.testing
from everalgo.types import ChatMessage, Foresight, MemCell
from everos.infra.ome.testing import FakeStrategyContext
from everos.memory.events import UserPipelineStarted
from everos.memory.strategies.extract_foresight import extract_foresight
mod = importlib.import_module("everos.memory.strategies.extract_foresight")
def _two_user_memcell() -> MemCell:
return MemCell(
items=[
ChatMessage(
id="m1",
role="user",
content="alice plans a trip",
timestamp=1_700_000_000_000,
sender_id="u_alice",
),
ChatMessage(
id="m2",
role="user",
content="bob will buy tickets",
timestamp=1_700_000_001_000,
sender_id="u_bob",
),
ChatMessage(
id="m3",
role="assistant",
content="sounds good",
timestamp=1_700_000_002_000,
sender_id="agent",
),
],
timestamp=1_700_000_002_000,
)
def _foresight(owner_id: str, text: str) -> Foresight:
return Foresight(
owner_id=owner_id,
foresight=text,
evidence="...",
timestamp=1_700_000_000_000,
)
def _event() -> UserPipelineStarted:
return UserPipelineStarted(
memcell_id="mc_a", session_id="s1", memcell=_two_user_memcell()
)
async def test_strategy_meta_is_attached() -> None:
meta = extract_foresight._ome_strategy_meta # type: ignore[attr-defined]
assert meta.name == "extract_foresight"
assert UserPipelineStarted in meta.trigger.on
assert meta.emits == frozenset()
assert meta.max_retries == 2
async def test_extracts_per_sender(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Per-sender extraction (like Episode, unlike AtomicFact's fan-out)."""
monkeypatch.setattr(mod, "_writer", None, raising=False)
with (
patch(
"everos.memory.strategies.extract_foresight.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.extract_foresight.ForesightExtractor"
) as mock_cls,
patch(
"everos.memory.strategies.extract_foresight.ForesightWriter"
) as mock_wcls,
structlog.testing.capture_logs() as captured,
):
# sender_ids in the strategy are sorted: alice first, bob second.
mock_cls.return_value.aextract = AsyncMock(
side_effect=[
[_foresight("u_alice", "trip to tokyo")],
[_foresight("u_bob", "buy plane tickets")],
]
)
mock_wcls.return_value.append_entries = AsyncMock(return_value=[])
await extract_foresight(_event(), FakeStrategyContext())
# Per-sender semantics: one LLM call per user sender.
assert mock_cls.return_value.aextract.await_count == 2
sender_id_calls = [
call.kwargs.get("sender_id")
for call in mock_cls.return_value.aextract.call_args_list
]
assert sender_id_calls == ["u_alice", "u_bob"]
# Per-owner batching: one batch call per owner; here each owner has 1
# foresight, so two batches each carrying 1 item.
assert mock_wcls.return_value.append_entries.call_count == 2
batched_owners = sorted(
c.args[0] for c in mock_wcls.return_value.append_entries.call_args_list
)
assert batched_owners == ["u_alice", "u_bob"]
matching = [e for e in captured if e.get("event") == "foresights_extracted"]
assert matching, "expected foresights_extracted log line"
record = matching[0]
assert record["count"] == 2
assert sorted(record["owner_ids"]) == ["u_alice", "u_bob"]
async def test_writes_md_for_each_foresight(
monkeypatch: pytest.MonkeyPatch,
) -> None:
foresights = [
Foresight(
owner_id="u_alice",
foresight="trip to tokyo",
evidence="said so",
timestamp=1_700_000_000_000,
),
Foresight(
owner_id="u_alice",
foresight="buy tickets",
evidence="confirmed",
timestamp=1_700_000_000_000,
start_time="2023-11-15",
duration_days=7,
),
]
monkeypatch.setattr(mod, "_writer", None, raising=False)
with (
patch(
"everos.memory.strategies.extract_foresight.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.extract_foresight.ForesightExtractor"
) as mock_cls,
patch(
"everos.memory.strategies.extract_foresight.ForesightWriter"
) as mock_wcls,
):
mock_cls.return_value.aextract = AsyncMock(return_value=foresights)
mock_wcls.return_value.append_entries = AsyncMock(return_value=[])
event = UserPipelineStarted(
memcell_id="mc_a",
session_id="s1",
memcell=MemCell(
items=[
ChatMessage(
id="m1",
role="user",
content="planning a trip",
timestamp=1_700_000_000_000,
sender_id="u_alice",
)
],
timestamp=1_700_000_000_000,
),
)
await extract_foresight(event, FakeStrategyContext())
# Single sender (u_alice) → one batch call with both foresights.
assert mock_wcls.return_value.append_entries.call_count == 1
batch_call = mock_wcls.return_value.append_entries.call_args
assert batch_call.args[0] == "u_alice"
items = batch_call.args[1]
assert len(items) == 2
# First foresight: no optional time fields
inline0, sections0 = items[0]
assert inline0["owner_id"] == "u_alice"
assert inline0["session_id"] == "s1"
assert inline0["parent_type"] == "memcell"
assert inline0["parent_id"] == "mc_a"
assert "sender_ids" not in inline0
assert "start_time" not in inline0
assert "duration_days" not in inline0
assert sections0 == {"Foresight": "trip to tokyo", "Evidence": "said so"}
# Second foresight: has start_time + duration_days
inline1, sections1 = items[1]
assert inline1["start_time"] == "2023-11-15"
assert inline1["duration_days"] == 7
assert sections1 == {"Foresight": "buy tickets", "Evidence": "confirmed"}
async def test_skips_when_memcell_has_no_messages(
monkeypatch: pytest.MonkeyPatch,
) -> None:
event = UserPipelineStarted(
memcell_id="mc_b",
session_id="s1",
memcell=MemCell(items=[], timestamp=1_700_000_000_000),
)
monkeypatch.setattr(mod, "_writer", None, raising=False)
with (
patch(
"everos.memory.strategies.extract_foresight.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.extract_foresight.ForesightExtractor"
) as mock_cls,
patch(
"everos.memory.strategies.extract_foresight.ForesightWriter"
) as mock_wcls,
structlog.testing.capture_logs() as captured,
):
mock_cls.return_value.aextract = AsyncMock(return_value=[])
mock_wcls.return_value.append_entries = AsyncMock(return_value=[])
ctx = FakeStrategyContext()
await extract_foresight(event, ctx)
matching = [e for e in captured if e.get("event") == "foresights_extracted"]
assert matching, "log line should still fire (count=0)"
assert matching[0]["count"] == 0
mock_wcls.return_value.append_entries.assert_not_called()

View File

@ -0,0 +1,387 @@
"""Tests for :func:`extract_user_profile`.
Heavy mocking — the strategy threads through ``cluster_repo`` (sqlite),
``memcell_repo`` (sqlite, payload deserialise), ``ProfileReader`` /
``ProfileWriter`` (md), and ``ProfileExtractor`` (algo). We mock all
seams so the test exercises the orchestration only.
"""
from __future__ import annotations
import asyncio
import importlib
from unittest.mock import AsyncMock, MagicMock, patch
import numpy as np
import pytest
from everalgo.clustering import Cluster as AlgoCluster
from everalgo.types import ChatMessage, MemCell
from everalgo.types import Profile as AlgoProfile
from everos.infra.ome.testing import FakeStrategyContext
from everos.infra.persistence.markdown import UserProfileFrontmatter
from everos.memory.events import ProfileClusterUpdated
from everos.memory.strategies._partition_locks import _reset_for_tests
from everos.memory.strategies.extract_user_profile import extract_user_profile
@pytest.fixture(autouse=True)
def _isolate_partition_locks() -> None:
_reset_for_tests()
def _event(
*,
owner_id: str = "u_alice",
memcell_id: str = "mc_aaaaaaaaaaa1",
cluster_id: str = "cl_user00000001",
) -> ProfileClusterUpdated:
return ProfileClusterUpdated(
memcell_id=memcell_id,
cluster_id=cluster_id,
owner_id=owner_id,
)
def _algo_cluster(*, cluster_id: str, members: list[str], last_ts: int) -> AlgoCluster:
return AlgoCluster(
id=cluster_id,
centroid=np.zeros(1024, dtype=np.float32),
count=len(members),
last_ts=last_ts,
preview=[],
members=members,
)
def _memcell_row(memcell_id: str, *, sender_id: str, ts_ms: int) -> MagicMock:
"""Stand-in for a sqlite Memcell row — only ``payload_json`` is read."""
cell = MemCell(
items=[
ChatMessage(
id=f"{memcell_id}_m1",
role="user",
content=f"hi from {sender_id}",
timestamp=ts_ms,
sender_id=sender_id,
),
],
timestamp=ts_ms,
)
row = MagicMock()
row.memcell_id = memcell_id
row.payload_json = cell.model_dump_json()
return row
async def test_strategy_meta_is_attached() -> None:
meta = extract_user_profile._ome_strategy_meta # type: ignore[attr-defined]
assert meta.name == "extract_user_profile"
assert ProfileClusterUpdated in meta.trigger.on
assert meta.emits == frozenset()
assert meta.max_retries == 2
@pytest.mark.asyncio
async def test_init_mode_writes_profile_when_no_existing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""No prior profile → ProfileExtractor invoked without ``old_profile``."""
cluster = _algo_cluster(
cluster_id="cl_user00000001",
members=["mc_aaaaaaaaaaa1"],
last_ts=1_700_000_001_000,
)
rows = [
_memcell_row("mc_aaaaaaaaaaa1", sender_id="u_alice", ts_ms=1_700_000_001_000)
]
new_profile = AlgoProfile.model_validate(
{
"owner_id": "u_alice",
"summary": "Alice is a hiker.",
"timestamp": 1_700_000_001_000,
"explicit_info": ["lives in tokyo"],
"implicit_traits": ["adventurous"],
}
)
with (
patch(
"everos.memory.strategies.extract_user_profile.cluster_repo"
) as mock_cluster_repo,
patch(
"everos.memory.strategies.extract_user_profile.memcell_repo"
) as mock_memcell_repo,
patch(
"everos.memory.strategies.extract_user_profile.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.extract_user_profile.ProfileExtractor"
) as mock_extractor_cls,
patch(
"everos.memory.strategies.extract_user_profile.ProfileReader"
) as mock_reader_cls,
patch(
"everos.memory.strategies.extract_user_profile.ProfileWriter"
) as mock_writer_cls,
):
mock_cluster_repo.list_for_owner = AsyncMock(return_value=[cluster])
mock_memcell_repo.find_by_ids = AsyncMock(return_value=rows)
mock_reader_cls.return_value.read = AsyncMock(return_value=None)
mock_writer_cls.return_value.write = AsyncMock(return_value=None)
mock_extractor_cls.return_value.aextract = AsyncMock(return_value=new_profile)
mod = importlib.import_module("everos.memory.strategies.extract_user_profile")
monkeypatch.setattr(mod, "_writer", None, raising=False)
monkeypatch.setattr(mod, "_reader", None, raising=False)
await extract_user_profile(_event(), FakeStrategyContext())
# INIT mode — old_profile is None.
extractor_call = mock_extractor_cls.return_value.aextract.call_args
assert extractor_call.kwargs["old_profile"] is None
assert extractor_call.kwargs["sender_id"] == "u_alice"
assert [mc.timestamp for mc in extractor_call.args[0]] == [1_700_000_001_000]
# Writer received the freshly built frontmatter.
write_call = mock_writer_cls.return_value.write.call_args
assert write_call.args[0] == "u_alice"
fm = write_call.kwargs["frontmatter"]
assert fm.user_id == "u_alice"
assert fm.summary == "Alice is a hiker."
assert fm.profile_timestamp_ms == 1_700_000_001_000
assert fm.explicit_info == ["lives in tokyo"]
assert fm.implicit_traits == ["adventurous"]
assert write_call.kwargs["body"] == "Alice is a hiker."
@pytest.mark.asyncio
async def test_update_mode_rehydrates_old_profile(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Existing profile → algo Profile rehydrated and passed as old_profile."""
cluster = _algo_cluster(
cluster_id="cl_user00000001",
members=["mc_aaaaaaaaaaa1"],
last_ts=1_700_000_002_000,
)
rows = [
_memcell_row("mc_aaaaaaaaaaa1", sender_id="u_alice", ts_ms=1_700_000_002_000)
]
existing_fm = UserProfileFrontmatter(
id="profile_u_alice",
user_id="u_alice",
summary="prior summary",
explicit_info=["prior fact"],
implicit_traits=["prior trait"],
profile_timestamp_ms=1_700_000_000_000,
)
new_profile = AlgoProfile.model_validate(
{
"owner_id": "u_alice",
"summary": "updated summary",
"timestamp": 1_700_000_002_000,
"explicit_info": ["prior fact", "new fact"],
"implicit_traits": ["prior trait"],
}
)
with (
patch(
"everos.memory.strategies.extract_user_profile.cluster_repo"
) as mock_cluster_repo,
patch(
"everos.memory.strategies.extract_user_profile.memcell_repo"
) as mock_memcell_repo,
patch(
"everos.memory.strategies.extract_user_profile.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.extract_user_profile.ProfileExtractor"
) as mock_extractor_cls,
patch(
"everos.memory.strategies.extract_user_profile.ProfileReader"
) as mock_reader_cls,
patch(
"everos.memory.strategies.extract_user_profile.ProfileWriter"
) as mock_writer_cls,
):
mock_cluster_repo.list_for_owner = AsyncMock(return_value=[cluster])
mock_memcell_repo.find_by_ids = AsyncMock(return_value=rows)
mock_reader_cls.return_value.read = AsyncMock(
return_value=(existing_fm, "prior summary")
)
mock_writer_cls.return_value.write = AsyncMock(return_value=None)
mock_extractor_cls.return_value.aextract = AsyncMock(return_value=new_profile)
mod = importlib.import_module("everos.memory.strategies.extract_user_profile")
monkeypatch.setattr(mod, "_writer", None, raising=False)
monkeypatch.setattr(mod, "_reader", None, raising=False)
await extract_user_profile(_event(), FakeStrategyContext())
# UPDATE mode — old_profile is the rehydrated algo type carrying prior fields.
extractor_call = mock_extractor_cls.return_value.aextract.call_args
old = extractor_call.kwargs["old_profile"]
assert isinstance(old, AlgoProfile)
assert old.summary == "prior summary"
assert old.timestamp == 1_700_000_000_000
assert old.model_dump()["explicit_info"] == ["prior fact"]
@pytest.mark.asyncio
async def test_skips_when_no_members(monkeypatch: pytest.MonkeyPatch) -> None:
"""An empty target cluster set (no fresh clusters) → no extractor call."""
# Existing profile timestamp newer than every cluster's last_ts → no
# target_cluster matches `last_ts > last_profile_ts`, but the current
# cluster_id should still force inclusion. Set the current cluster id
# to a non-existent value to drop everything.
stale_cluster = _algo_cluster(
cluster_id="cl_other000001",
members=["mc_other00000"],
last_ts=1_600_000_000_000,
)
existing_fm = UserProfileFrontmatter(
id="profile_u_alice",
user_id="u_alice",
summary="prior",
explicit_info=[],
implicit_traits=[],
profile_timestamp_ms=1_900_000_000_000,
)
with (
patch(
"everos.memory.strategies.extract_user_profile.cluster_repo"
) as mock_cluster_repo,
patch(
"everos.memory.strategies.extract_user_profile.memcell_repo"
) as mock_memcell_repo,
patch(
"everos.memory.strategies.extract_user_profile.ProfileExtractor"
) as mock_extractor_cls,
patch(
"everos.memory.strategies.extract_user_profile.ProfileReader"
) as mock_reader_cls,
patch(
"everos.memory.strategies.extract_user_profile.ProfileWriter"
) as mock_writer_cls,
):
mock_cluster_repo.list_for_owner = AsyncMock(return_value=[stale_cluster])
mock_memcell_repo.find_by_ids = AsyncMock(return_value=[])
mock_reader_cls.return_value.read = AsyncMock(
return_value=(existing_fm, "prior")
)
mock_writer_cls.return_value.write = AsyncMock(return_value=None)
mock_extractor_cls.return_value.aextract = AsyncMock()
mod = importlib.import_module("everos.memory.strategies.extract_user_profile")
monkeypatch.setattr(mod, "_writer", None, raising=False)
monkeypatch.setattr(mod, "_reader", None, raising=False)
await extract_user_profile(
_event(cluster_id="cl_unknown00000"), FakeStrategyContext()
)
mock_extractor_cls.return_value.aextract.assert_not_called()
mock_writer_cls.return_value.write.assert_not_called()
# ── partition lock (owner_id-level serialisation) ────────────────────────
async def _run_serialisation_probe(
owner_a: str, owner_b: str, monkeypatch: pytest.MonkeyPatch
) -> list[str]:
"""Drive two extract_user_profile runs and record entry/exit order."""
log: list[str] = []
async def mock_aextract(_memcells, *, sender_id, **_kwargs):
log.append(f"enter:{sender_id}")
await asyncio.sleep(0.01)
log.append(f"leave:{sender_id}")
return AlgoProfile(
owner_id=sender_id,
summary="summary",
timestamp=1_700_000_000_000,
explicit_info=[],
implicit_traits=[],
)
cluster_a = _algo_cluster(
cluster_id="cl_a", members=["mc_a"], last_ts=1_700_000_000_000
)
cluster_b = _algo_cluster(
cluster_id="cl_b", members=["mc_b"], last_ts=1_700_000_000_000
)
with (
patch(
"everos.memory.strategies.extract_user_profile.cluster_repo"
) as mock_cluster_repo,
patch(
"everos.memory.strategies.extract_user_profile.memcell_repo"
) as mock_memcell_repo,
patch(
"everos.memory.strategies.extract_user_profile.ProfileReader"
) as mock_reader_cls,
patch(
"everos.memory.strategies.extract_user_profile.ProfileWriter"
) as mock_writer_cls,
patch(
"everos.memory.strategies.extract_user_profile.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.extract_user_profile.ProfileExtractor"
) as mock_extractor_cls,
):
mock_cluster_repo.list_for_owner = AsyncMock(
side_effect=lambda owner, _kind, **_kw: (
[cluster_a] if owner == owner_a else [cluster_b]
)
)
mock_memcell_repo.find_by_ids = AsyncMock(
side_effect=lambda ids: [
_memcell_row(ids[0], sender_id="sender", ts_ms=1_700_000_000_000)
]
)
mock_reader_cls.return_value.read = AsyncMock(return_value=[])
mock_writer_cls.return_value.write = AsyncMock(return_value=None)
mock_extractor_cls.return_value.aextract = mock_aextract
mod = importlib.import_module("everos.memory.strategies.extract_user_profile")
monkeypatch.setattr(mod, "_reader", None, raising=False)
monkeypatch.setattr(mod, "_writer", None, raising=False)
await asyncio.gather(
extract_user_profile(
_event(owner_id=owner_a, cluster_id="cl_a"), FakeStrategyContext()
),
extract_user_profile(
_event(owner_id=owner_b, cluster_id="cl_b"), FakeStrategyContext()
),
)
return log
async def test_partition_lock_serialises_runs_on_same_owner(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Two runs sharing ``owner_id`` must not overlap critical sections."""
log = await _run_serialisation_probe("u_alice", "u_alice", monkeypatch)
assert log in (
["enter:u_alice", "leave:u_alice", "enter:u_alice", "leave:u_alice"],
)
# Same-owner runs always log "u_alice" twice — verify strict ordering
# by tagging entry/leave pairs are adjacent (no interleave possible).
assert log[0].startswith("enter:") and log[1].startswith("leave:")
assert log[2].startswith("enter:") and log[3].startswith("leave:")
async def test_partition_lock_lets_different_owners_run_in_parallel(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Runs on distinct ``owner_id`` must overlap (no false serialisation)."""
log = await _run_serialisation_probe("u_alice", "u_bob", monkeypatch)
assert log.index("enter:u_alice") < log.index("leave:u_bob")
assert log.index("enter:u_bob") < log.index("leave:u_alice")

View File

@ -0,0 +1,126 @@
"""Tests for :mod:`everos.memory.strategies._partition_locks`.
The helper is the foundation under every strategy that performs a
read → modify → write on shared state; its own behaviour (lock reuse,
strategy isolation, FIFO serialisation, parallel keys) is exercised
here, in isolation from any business strategy.
"""
from __future__ import annotations
import asyncio
import pytest
from everos.memory.strategies._partition_locks import (
_reset_for_tests,
get_partition_lock,
)
@pytest.fixture(autouse=True)
def _isolate_locks() -> None:
"""Each test gets a clean registry — no inherited holders / waiters."""
_reset_for_tests()
def test_same_strategy_same_key_returns_identical_lock() -> None:
"""Repeat lookups must reuse the lock (otherwise serialisation breaks)."""
a = get_partition_lock("strategy_x", "k1")
b = get_partition_lock("strategy_x", "k1")
assert a is b
def test_same_strategy_different_keys_return_distinct_locks() -> None:
"""Different partition keys must not block each other."""
assert get_partition_lock("strategy_x", "k1") is not get_partition_lock(
"strategy_x", "k2"
)
def test_different_strategies_share_no_locks_for_identical_key() -> None:
"""Strategy namespaces are independent — same key string is two locks."""
assert get_partition_lock("strategy_x", "k1") is not get_partition_lock(
"strategy_y", "k1"
)
def test_reset_for_tests_drops_every_lock() -> None:
"""After reset the registry is empty; the next lookup returns a fresh lock."""
before = get_partition_lock("strategy_x", "k1")
_reset_for_tests()
after = get_partition_lock("strategy_x", "k1")
assert before is not after
async def test_same_key_serialises_concurrent_acquirers() -> None:
"""Two tasks contending the same key must not overlap critical sections."""
log: list[str] = []
async def worker(tag: str) -> None:
async with get_partition_lock("strategy_x", "k1"):
log.append(f"enter:{tag}")
await asyncio.sleep(0.01)
log.append(f"leave:{tag}")
await asyncio.gather(worker("a"), worker("b"))
# The two critical sections must run one after the other (either order
# is fine — asyncio scheduling decides who acquires first).
assert log in (
["enter:a", "leave:a", "enter:b", "leave:b"],
["enter:b", "leave:b", "enter:a", "leave:a"],
)
async def test_different_keys_run_in_parallel() -> None:
"""Two tasks on distinct keys must overlap (no false serialisation)."""
log: list[str] = []
async def worker(key: str, tag: str) -> None:
async with get_partition_lock("strategy_x", key):
log.append(f"enter:{tag}")
await asyncio.sleep(0.01)
log.append(f"leave:{tag}")
await asyncio.gather(worker("k1", "a"), worker("k2", "b"))
# Both must enter before either leaves — proves no cross-key blocking.
assert log.index("enter:a") < log.index("leave:b")
assert log.index("enter:b") < log.index("leave:a")
async def test_concurrent_acquirers_fifo_fairness() -> None:
"""asyncio.Lock is FIFO — queued waiters acquire in arrival order."""
log: list[str] = []
holder_in = asyncio.Event()
holder_release = asyncio.Event()
async def holder() -> None:
async with get_partition_lock("strategy_x", "k1"):
holder_in.set()
await holder_release.wait()
log.append("leave:holder")
async def waiter(tag: str, arrived: asyncio.Event) -> None:
arrived.set()
async with get_partition_lock("strategy_x", "k1"):
log.append(f"enter:{tag}")
arrived_a = asyncio.Event()
arrived_b = asyncio.Event()
task_holder = asyncio.create_task(holder())
await holder_in.wait() # holder owns the lock
# Enqueue A first, then B — Lock's deque preserves this order.
task_a = asyncio.create_task(waiter("a", arrived_a))
await arrived_a.wait()
await asyncio.sleep(0) # let A actually park on the lock
task_b = asyncio.create_task(waiter("b", arrived_b))
await arrived_b.wait()
await asyncio.sleep(0) # let B park on the lock
holder_release.set()
await asyncio.gather(task_holder, task_a, task_b)
assert log == ["leave:holder", "enter:a", "enter:b"]

View File

@ -0,0 +1,56 @@
"""Test strategy package exports and OME engine registration."""
from __future__ import annotations
import importlib
from pathlib import Path
import pytest
from everos.memory.strategies import (
extract_agent_case,
extract_agent_skill,
extract_atomic_facts,
extract_foresight,
extract_user_profile,
trigger_profile_clustering,
trigger_skill_clustering,
)
def test_strategies_are_re_exported_from_package() -> None:
for fn, name in [
(extract_atomic_facts, "extract_atomic_facts"),
(extract_foresight, "extract_foresight"),
(extract_agent_case, "extract_agent_case"),
(trigger_skill_clustering, "trigger_skill_clustering"),
(extract_agent_skill, "extract_agent_skill"),
(trigger_profile_clustering, "trigger_profile_clustering"),
(extract_user_profile, "extract_user_profile"),
]:
assert fn._ome_strategy_meta.name == name # type: ignore[attr-defined]
async def test_get_engine_registers_all_strategies(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from everos.core.persistence import MemoryRoot
svc = importlib.import_module("everos.service.memorize")
monkeypatch.setattr(
MemoryRoot, "default", classmethod(lambda cls: MemoryRoot(root=tmp_path))
)
monkeypatch.setattr(svc, "_ome_engine", None, raising=False)
engine = svc._get_engine()
names = {m.name for m in engine._registry.all()} # noqa: SLF001 — test introspection
assert names == {
"extract_atomic_facts",
"extract_foresight",
"extract_agent_case",
"trigger_skill_clustering",
"extract_agent_skill",
"trigger_profile_clustering",
"extract_user_profile",
}

View File

@ -0,0 +1,202 @@
"""Real md round-trip tests: strategy runs → writer writes → reader finds file."""
from __future__ import annotations
import uuid
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from everalgo.types import AgentCase, AtomicFact, ChatMessage, Foresight, MemCell
from everos.core.persistence import MemoryRoot
from everos.infra.ome.testing import FakeStrategyContext
from everos.infra.persistence.markdown import (
AgentCaseReader,
AtomicFactReader,
ForesightReader,
)
from everos.memory.events import AgentPipelineStarted, UserPipelineStarted
from everos.memory.strategies.extract_agent_case import extract_agent_case
from everos.memory.strategies.extract_atomic_facts import extract_atomic_facts
from everos.memory.strategies.extract_foresight import extract_foresight
def _event_for(owner: str) -> UserPipelineStarted:
return UserPipelineStarted(
memcell_id="mc_a",
session_id="s1",
memcell=MemCell(
items=[
ChatMessage(
id="m1",
role="user",
content="hi",
timestamp=1_700_000_000_000,
sender_id=owner,
),
],
timestamp=1_700_000_000_000,
),
)
def _agent_event() -> AgentPipelineStarted:
return AgentPipelineStarted(
memcell_id="mc_a",
session_id="s1",
memcell=MemCell(
items=[
ChatMessage(
id="m1",
role="user",
content="please summarise",
timestamp=1_700_000_000_000,
sender_id="u_alice",
),
ChatMessage(
id="m2",
role="assistant",
content="here's the summary",
timestamp=1_700_000_001_000,
sender_id="agent_42",
),
],
timestamp=1_700_000_001_000,
),
)
async def test_atomic_facts_round_trip(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
import importlib
af_mod = importlib.import_module("everos.memory.strategies.extract_atomic_facts")
monkeypatch.setattr(
MemoryRoot, "default", classmethod(lambda cls: MemoryRoot(root=tmp_path))
)
monkeypatch.setattr(af_mod, "_writer", None, raising=False)
facts = [
AtomicFact(
owner_id="u_alice",
content="alice likes hiking",
timestamp=1_700_000_000_000,
),
AtomicFact(
owner_id="u_alice",
content="alice lives in tokyo",
timestamp=1_700_000_000_000,
),
]
with (
patch(
"everos.memory.strategies.extract_atomic_facts.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.extract_atomic_facts.AtomicFactExtractor"
) as mock_ext,
):
mock_ext.return_value.aextract = AsyncMock(return_value=facts)
await extract_atomic_facts(_event_for("u_alice"), FakeStrategyContext())
reader = AtomicFactReader(root=MemoryRoot(root=tmp_path))
path = reader.path_for("u_alice")
assert path.is_file(), f"expected md at {path}"
content = path.read_text(encoding="utf-8")
assert "alice likes hiking" in content
assert "alice lives in tokyo" in content
async def test_foresights_round_trip(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
import importlib
fs_mod = importlib.import_module("everos.memory.strategies.extract_foresight")
monkeypatch.setattr(
MemoryRoot, "default", classmethod(lambda cls: MemoryRoot(root=tmp_path))
)
monkeypatch.setattr(fs_mod, "_writer", None, raising=False)
foresights = [
Foresight(
owner_id="u_alice",
foresight="plans trip to tokyo",
evidence="said so",
timestamp=1_700_000_000_000,
),
]
with (
patch(
"everos.memory.strategies.extract_foresight.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.extract_foresight.ForesightExtractor"
) as mock_ext,
):
mock_ext.return_value.aextract = AsyncMock(return_value=foresights)
await extract_foresight(_event_for("u_alice"), FakeStrategyContext())
reader = ForesightReader(root=MemoryRoot(root=tmp_path))
path = reader.path_for("u_alice")
assert path.is_file(), f"expected md at {path}"
content = path.read_text(encoding="utf-8")
assert "plans trip to tokyo" in content
assert "said so" in content
async def test_agent_case_round_trip(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
import importlib
ac_mod = importlib.import_module("everos.memory.strategies.extract_agent_case")
monkeypatch.setattr(
MemoryRoot, "default", classmethod(lambda cls: MemoryRoot(root=tmp_path))
)
monkeypatch.setattr(ac_mod, "_writer", None, raising=False)
cases = [
AgentCase(
id=uuid.uuid4().hex,
timestamp=1_700_000_001_000,
task_intent="summarise the doc",
approach="read then condense",
quality_score=0.82,
key_insight="batch-then-summarise",
)
]
with (
patch(
"everos.memory.strategies.extract_agent_case.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.extract_agent_case.AgentCaseExtractor"
) as mock_ext,
):
mock_ext.return_value.aextract = AsyncMock(return_value=cases)
await extract_agent_case(_agent_event(), FakeStrategyContext())
reader = AgentCaseReader(root=MemoryRoot(root=tmp_path))
path = reader.path_for("agent_42")
assert path.is_file(), f"expected md at {path}"
content = path.read_text(encoding="utf-8")
assert "summarise the doc" in content
assert "read then condense" in content
assert "batch-then-summarise" in content
# quality_score must land in inline (cascade requires it via require_float).
assert "quality_score" in content

View File

@ -0,0 +1,284 @@
"""Contract: strategy-written md must round-trip through cascade handler.
Guards against silent-breakage class: strategy writes section keys
(e.g. ``{"fact": ...}``) that the cascade handler reads under a different
case (e.g. ``sections.get("Fact")``). Without this contract, the worker
still upserts a LanceDB row but with empty ``fact`` / ``foresight``
text, empty BM25 tokens, and a vector for the empty string — search
fails silently. Earlier unit tests stop at the strategy boundary (mock
the writer) or at the writer boundary (skip the strategy); neither
catches a key-name drift.
"""
from __future__ import annotations
import importlib
import uuid
from pathlib import Path
from unittest.mock import AsyncMock, patch
import anyio
import pytest
from everalgo.types import AgentCase, AtomicFact, ChatMessage, Foresight, MemCell
from everos.component.embedding import EmbeddingProvider
from everos.component.tokenizer import Tokenizer
from everos.core.persistence import MarkdownReader, MemoryRoot
from everos.infra.ome.testing import FakeStrategyContext
from everos.memory.cascade.handlers import (
AgentCaseHandler,
AtomicFactHandler,
ForesightHandler,
HandlerDeps,
)
from everos.memory.cascade.handlers._daily_log_base import ParsedEntry
from everos.memory.events import AgentPipelineStarted, UserPipelineStarted
from everos.memory.strategies.extract_agent_case import extract_agent_case
from everos.memory.strategies.extract_atomic_facts import extract_atomic_facts
from everos.memory.strategies.extract_foresight import extract_foresight
class _StubTokenizer(Tokenizer):
def tokenize(self, text): # type: ignore[no-untyped-def]
return [tok for tok in text.split() if tok]
def tokenize_batch(self, texts): # type: ignore[no-untyped-def]
return [self.tokenize(t) for t in texts]
class _StubEmbedder(EmbeddingProvider):
dim = 1024
async def embed(self, text): # type: ignore[no-untyped-def]
return [0.0] * self.dim
async def embed_batch(self, texts): # type: ignore[no-untyped-def]
return [await self.embed(t) for t in texts]
def _event(owner_id: str) -> UserPipelineStarted:
return UserPipelineStarted(
memcell_id="mc_a",
session_id="s1",
memcell=MemCell(
items=[
ChatMessage(
id="m1",
role="user",
content="hi",
timestamp=1_700_000_000_000,
sender_id=owner_id,
),
],
timestamp=1_700_000_000_000,
),
)
async def _build_row_from_md(
handler: AtomicFactHandler | ForesightHandler | AgentCaseHandler,
md_root: Path,
md_glob: str,
*,
owner_id: str = "u_alice",
owner_type: str = "user",
):
md_files: list[anyio.Path] = []
async for p in anyio.Path(md_root).glob(md_glob):
md_files.append(p)
assert len(md_files) == 1, f"expected exactly one md, got: {md_files}"
md_abs = Path(md_files[0])
rel = str(md_abs.relative_to(md_root))
parsed = await MarkdownReader.read(md_abs)
assert parsed.entries, "writer should have produced at least one entry"
entry = parsed.entries[0]
structured = entry.as_structured()
pe = ParsedEntry(
entry_id=entry.id,
structured=structured,
content_sha256=handler._content_sha256(structured), # noqa: SLF001
)
return await handler._build_row( # noqa: SLF001
owner_id=owner_id,
owner_type=owner_type,
md_path=rel,
entry=pe,
)
async def test_atomic_fact_strategy_md_feeds_handler_with_content(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Strategy → md → AtomicFactHandler must carry the fact text intact."""
af_mod = importlib.import_module("everos.memory.strategies.extract_atomic_facts")
monkeypatch.setattr(
MemoryRoot, "default", classmethod(lambda cls: MemoryRoot(root=tmp_path))
)
monkeypatch.setattr(af_mod, "_writer", None, raising=False)
facts = [
AtomicFact(
owner_id="u_alice",
content="alice likes hiking",
timestamp=1_700_000_000_000,
),
]
with (
patch(
"everos.memory.strategies.extract_atomic_facts.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.extract_atomic_facts.AtomicFactExtractor"
) as mock_ext,
):
mock_ext.return_value.aextract = AsyncMock(return_value=facts)
await extract_atomic_facts(_event("u_alice"), FakeStrategyContext())
handler = AtomicFactHandler(
HandlerDeps(
memory_root=MemoryRoot(root=tmp_path),
embedder=_StubEmbedder(),
tokenizer=_StubTokenizer(),
)
)
row = await _build_row_from_md(
handler, tmp_path, "*/*/users/u_alice/.atomic_facts/atomic_fact-*.md"
)
# Regression guard: section key drift would land here as fact="".
assert row.fact == "alice likes hiking"
assert row.fact_tokens == "alice likes hiking"
assert len(row.vector) == 1024
async def test_foresight_strategy_md_feeds_handler_with_content(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Strategy → md → ForesightHandler must carry foresight + evidence text."""
fs_mod = importlib.import_module("everos.memory.strategies.extract_foresight")
monkeypatch.setattr(
MemoryRoot, "default", classmethod(lambda cls: MemoryRoot(root=tmp_path))
)
monkeypatch.setattr(fs_mod, "_writer", None, raising=False)
foresights = [
Foresight(
owner_id="u_alice",
foresight="plans trip to tokyo",
evidence="said so explicitly",
timestamp=1_700_000_000_000,
),
]
with (
patch(
"everos.memory.strategies.extract_foresight.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.extract_foresight.ForesightExtractor"
) as mock_ext,
):
mock_ext.return_value.aextract = AsyncMock(return_value=foresights)
await extract_foresight(_event("u_alice"), FakeStrategyContext())
handler = ForesightHandler(
HandlerDeps(
memory_root=MemoryRoot(root=tmp_path),
embedder=_StubEmbedder(),
tokenizer=_StubTokenizer(),
)
)
row = await _build_row_from_md(
handler, tmp_path, "*/*/users/u_alice/.foresights/foresight-*.md"
)
# Regression guard: section key drift would land here as foresight="".
assert row.foresight == "plans trip to tokyo"
assert row.foresight_tokens == "plans trip to tokyo"
assert row.evidence == "said so explicitly"
assert row.evidence_tokens == "said so explicitly"
assert len(row.vector) == 1024
def _agent_event() -> AgentPipelineStarted:
return AgentPipelineStarted(
memcell_id="mc_a",
session_id="s1",
memcell=MemCell(
items=[
ChatMessage(
id="m1",
role="user",
content="please summarise",
timestamp=1_700_000_000_000,
sender_id="u_alice",
),
ChatMessage(
id="m2",
role="assistant",
content="here's the summary",
timestamp=1_700_000_001_000,
sender_id="agent_42",
),
],
timestamp=1_700_000_001_000,
),
)
async def test_agent_case_strategy_md_feeds_handler_with_content(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Strategy → md → AgentCaseHandler carries task_intent, approach, score."""
ac_mod = importlib.import_module("everos.memory.strategies.extract_agent_case")
monkeypatch.setattr(
MemoryRoot, "default", classmethod(lambda cls: MemoryRoot(root=tmp_path))
)
monkeypatch.setattr(ac_mod, "_writer", None, raising=False)
cases = [
AgentCase(
id=uuid.uuid4().hex,
timestamp=1_700_000_001_000,
task_intent="summarise the doc",
approach="read + condense",
quality_score=0.85,
key_insight="batch-then-summarise",
)
]
with (
patch(
"everos.memory.strategies.extract_agent_case.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.extract_agent_case.AgentCaseExtractor"
) as mock_ext,
):
mock_ext.return_value.aextract = AsyncMock(return_value=cases)
await extract_agent_case(_agent_event(), FakeStrategyContext())
handler = AgentCaseHandler(
HandlerDeps(
memory_root=MemoryRoot(root=tmp_path),
embedder=_StubEmbedder(),
tokenizer=_StubTokenizer(),
)
)
row = await _build_row_from_md(
handler,
tmp_path,
"*/*/agents/agent_42/.cases/agent_case-*.md",
owner_id="agent_42",
owner_type="agent",
)
# Regression guard: section-key drift or missing quality_score inline
# would surface as empty strings / require_float failure.
assert row.task_intent == "summarise the doc"
assert row.task_intent_tokens == "summarise the doc"
assert row.approach == "read + condense"
assert row.approach_tokens == "read + condense"
assert row.key_insight == "batch-then-summarise"
assert row.quality_score == 0.85
assert row.owner_id == "agent_42"
assert row.owner_type == "agent"
assert len(row.vector) == 1024

View File

@ -0,0 +1,235 @@
"""Tests for :func:`trigger_profile_clustering`.
Mirrors the skill-side test layout: mock embedder + cluster_repo +
cluster_by_geometry, drive the strategy via :class:`FakeStrategyContext`,
verify a single :class:`ProfileClusterUpdated` event is emitted.
"""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import numpy as np
import pytest
import structlog.testing
from everalgo.clustering import Cluster as AlgoCluster
from everos.infra.ome.testing import FakeStrategyContext
from everos.memory.events import EpisodeExtracted, ProfileClusterUpdated
from everos.memory.strategies._partition_locks import _reset_for_tests
from everos.memory.strategies.trigger_profile_clustering import (
trigger_profile_clustering,
)
@pytest.fixture(autouse=True)
def _isolate_partition_locks() -> None:
_reset_for_tests()
def _event(
*,
owner_id: str = "u_alice",
memcell_id: str = "mc_aaaaaaaaaaa1",
episode_text: str = "alice likes hiking",
episode_timestamp_ms: int = 1_700_000_001_000,
) -> EpisodeExtracted:
return EpisodeExtracted(
memcell_id=memcell_id,
episode_entry_id="ep_20260517_0001",
episode_text=episode_text,
episode_timestamp_ms=episode_timestamp_ms,
owner_id=owner_id,
)
async def test_strategy_meta_is_attached() -> None:
meta = trigger_profile_clustering._ome_strategy_meta # type: ignore[attr-defined]
assert meta.name == "trigger_profile_clustering"
assert EpisodeExtracted in meta.trigger.on
assert meta.emits == frozenset({ProfileClusterUpdated})
assert meta.max_retries == 2
@pytest.mark.asyncio
async def test_creates_new_cluster_when_no_existing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Empty existing → cluster_by_geometry returns None → new cluster persisted."""
embedder = MagicMock()
embedder.embed = AsyncMock(return_value=[0.1] * 1024)
ctx = FakeStrategyContext()
with (
patch(
"everos.memory.strategies.trigger_profile_clustering.get_embedder",
return_value=embedder,
),
patch(
"everos.memory.strategies.trigger_profile_clustering.cluster_repo"
) as mock_repo,
patch(
"everos.memory.strategies.trigger_profile_clustering.cluster_by_geometry",
new=MagicMock(return_value=None),
) as mock_cluster,
patch(
"everos.memory.strategies.trigger_profile_clustering.mint_cluster_id",
return_value="cl_newuser00001",
),
structlog.testing.capture_logs() as captured,
):
mock_repo.list_for_owner = AsyncMock(return_value=[])
mock_repo.upsert_with_members = AsyncMock(return_value=None)
await trigger_profile_clustering(_event(), ctx)
args, _ = mock_cluster.call_args
new_cluster, existing = args
assert isinstance(new_cluster, AlgoCluster)
assert new_cluster.id == "cl_newuser00001"
assert new_cluster.count == 1
assert new_cluster.last_ts == 1_700_000_001_000
assert new_cluster.members == ["mc_aaaaaaaaaaa1"]
assert new_cluster.preview == ["alice likes hiking"]
assert existing == []
upsert_args = mock_repo.upsert_with_members.call_args
persisted = upsert_args.args[0]
assert persisted.id == "cl_newuser00001"
assert upsert_args.kwargs == {
"owner_id": "u_alice",
"owner_type": "user",
"kind": "user_memory",
"member_type": "memcell",
"app_id": "default",
"project_id": "default",
}
emitted = [e for e in ctx.emitted if isinstance(e, ProfileClusterUpdated)]
assert len(emitted) == 1
assert emitted[0].memcell_id == "mc_aaaaaaaaaaa1"
assert emitted[0].cluster_id == "cl_newuser00001"
assert emitted[0].owner_id == "u_alice"
matching = [r for r in captured if r.get("event") == "profile_cluster_updated"]
assert matching, "expected profile_cluster_updated log line"
@pytest.mark.asyncio
async def test_merges_into_existing_cluster_when_algo_matches() -> None:
"""algo returns merged Cluster → persisted under the existing id."""
embedder = MagicMock()
embedder.embed = AsyncMock(return_value=[0.2] * 1024)
ctx = FakeStrategyContext()
existing_cluster = AlgoCluster(
id="cl_existing0001",
centroid=np.array([0.15] * 1024, dtype=np.float32),
count=1,
last_ts=1_700_000_000_000,
preview=["earlier episode"],
members=["mc_zzzzzzzzzzz0"],
)
merged_cluster = AlgoCluster(
id="cl_existing0001",
centroid=np.array([0.17] * 1024, dtype=np.float32),
count=2,
last_ts=1_700_000_001_000,
preview=["earlier episode", "alice likes hiking"],
members=["mc_zzzzzzzzzzz0", "mc_aaaaaaaaaaa1"],
)
with (
patch(
"everos.memory.strategies.trigger_profile_clustering.get_embedder",
return_value=embedder,
),
patch(
"everos.memory.strategies.trigger_profile_clustering.cluster_repo"
) as mock_repo,
patch(
"everos.memory.strategies.trigger_profile_clustering.cluster_by_geometry",
new=MagicMock(return_value=merged_cluster),
),
):
mock_repo.list_for_owner = AsyncMock(return_value=[existing_cluster])
mock_repo.upsert_with_members = AsyncMock(return_value=None)
await trigger_profile_clustering(_event(), ctx)
persisted = mock_repo.upsert_with_members.call_args.args[0]
assert persisted.id == "cl_existing0001"
assert persisted.count == 2
emitted = [e for e in ctx.emitted if isinstance(e, ProfileClusterUpdated)]
assert len(emitted) == 1
assert emitted[0].cluster_id == "cl_existing0001"
# ── partition lock (owner_id-level serialisation) ────────────────────────
async def _run_serialisation_probe(owner_a: str, owner_b: str) -> list[str]:
"""Drive two trigger_profile_clustering runs and record entry/exit order."""
log: list[str] = []
def mock_cluster_by_geometry(_new_cluster, _existing):
# Sync, matching the real algo signature (must not be awaited).
return None
async def mock_upsert(cluster, **_kwargs):
# Delay inside the partition-lock critical section so two concurrent
# runs on the same owner are observably serialised. cluster_by_geometry
# is synchronous now, so the await point moves here.
mid = cluster.members[0]
log.append(f"enter:{mid}")
await asyncio.sleep(0.01)
log.append(f"leave:{mid}")
mock_embedder = MagicMock()
mock_embedder.embed = AsyncMock(return_value=np.zeros(1024, dtype=np.float32))
with (
patch(
"everos.memory.strategies.trigger_profile_clustering.get_embedder",
return_value=mock_embedder,
),
patch(
"everos.memory.strategies.trigger_profile_clustering.cluster_repo"
) as mock_repo,
patch(
"everos.memory.strategies.trigger_profile_clustering.cluster_by_geometry",
new=mock_cluster_by_geometry,
),
):
mock_repo.list_for_owner = AsyncMock(return_value=[])
mock_repo.upsert_with_members = mock_upsert
await asyncio.gather(
trigger_profile_clustering(
_event(owner_id=owner_a, memcell_id="mc_run_a"),
FakeStrategyContext(),
),
trigger_profile_clustering(
_event(owner_id=owner_b, memcell_id="mc_run_b"),
FakeStrategyContext(),
),
)
return log
async def test_partition_lock_serialises_runs_on_same_owner() -> None:
"""Two runs sharing ``owner_id`` must not overlap critical sections."""
log = await _run_serialisation_probe("u_alice", "u_alice")
assert log in (
["enter:mc_run_a", "leave:mc_run_a", "enter:mc_run_b", "leave:mc_run_b"],
["enter:mc_run_b", "leave:mc_run_b", "enter:mc_run_a", "leave:mc_run_a"],
)
async def test_partition_lock_lets_different_owners_run_in_parallel() -> None:
"""Runs on distinct ``owner_id`` must overlap (no false serialisation)."""
log = await _run_serialisation_probe("u_alice", "u_bob")
assert log.index("enter:mc_run_a") < log.index("leave:mc_run_b")
assert log.index("enter:mc_run_b") < log.index("leave:mc_run_a")

View File

@ -0,0 +1,277 @@
"""Tests for :func:`trigger_skill_clustering`.
Mock surface: ``cluster_by_llm``, ``get_embedder``, ``get_llm_client``,
``cluster_repo`` — strategy is wired to use them as module-level imports
so each ``patch`` swaps the symbol in the strategy module's namespace.
"""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import numpy as np
import pytest
import structlog.testing
from everalgo.clustering import Cluster as AlgoCluster
from everos.infra.ome.testing import FakeStrategyContext
from everos.memory.events import AgentCaseExtracted, SkillClusterUpdated
from everos.memory.strategies._partition_locks import _reset_for_tests
from everos.memory.strategies.trigger_skill_clustering import (
trigger_skill_clustering,
)
@pytest.fixture(autouse=True)
def _isolate_partition_locks() -> None:
_reset_for_tests()
def _event(
*,
quality_score: float = 0.8,
case_entry_id: str = "ac_20260517_0001",
agent_id: str = "agent_42",
task_intent: str = "summarise the doc",
case_timestamp_ms: int = 1_700_000_001_000,
) -> AgentCaseExtracted:
return AgentCaseExtracted(
memcell_id="mc_a",
case_entry_id=case_entry_id,
task_intent=task_intent,
quality_score=quality_score,
case_timestamp_ms=case_timestamp_ms,
agent_id=agent_id,
)
async def test_strategy_meta_is_attached() -> None:
meta = trigger_skill_clustering._ome_strategy_meta # type: ignore[attr-defined]
assert meta.name == "trigger_skill_clustering"
assert AgentCaseExtracted in meta.trigger.on
assert meta.emits == frozenset({SkillClusterUpdated})
assert meta.max_retries == 2
async def test_skips_when_quality_score_below_threshold() -> None:
"""quality_score < 0.2 → log + early return; no embedding, no LLM, no repo call."""
ctx = FakeStrategyContext()
with (
patch(
"everos.memory.strategies.trigger_skill_clustering.get_embedder"
) as mock_emb,
patch(
"everos.memory.strategies.trigger_skill_clustering.cluster_repo"
) as mock_repo,
patch(
"everos.memory.strategies.trigger_skill_clustering.cluster_by_llm"
) as mock_cluster,
structlog.testing.capture_logs() as captured,
):
await trigger_skill_clustering(_event(quality_score=0.1), ctx)
mock_emb.assert_not_called()
mock_repo.list_for_owner.assert_not_called()
mock_cluster.assert_not_called()
assert ctx.emitted == []
matching = [
e for e in captured if e.get("event") == "skill_clustering_skipped_low_quality"
]
assert matching, "expected low-quality skip log line"
async def test_creates_new_cluster_when_no_existing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Empty existing list → cluster_by_llm returns None → new cluster persisted."""
embedder = MagicMock()
embedder.embed = AsyncMock(return_value=[0.1] * 1024)
ctx = FakeStrategyContext()
with (
patch(
"everos.memory.strategies.trigger_skill_clustering.get_embedder",
return_value=embedder,
),
patch(
"everos.memory.strategies.trigger_skill_clustering.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.trigger_skill_clustering.cluster_repo"
) as mock_repo,
patch(
"everos.memory.strategies.trigger_skill_clustering.cluster_by_llm",
new=AsyncMock(return_value=None),
) as mock_cluster,
patch(
"everos.memory.strategies.trigger_skill_clustering.mint_cluster_id",
return_value="cl_newxxxx0001",
),
):
mock_repo.list_for_owner = AsyncMock(return_value=[])
mock_repo.upsert_with_members = AsyncMock(return_value=None)
await trigger_skill_clustering(_event(), ctx)
# cluster_by_llm called with the size-1 new cluster + empty existing.
args, kwargs = mock_cluster.call_args
new_cluster, existing = args
assert isinstance(new_cluster, AlgoCluster)
assert new_cluster.id == "cl_newxxxx0001"
assert new_cluster.count == 1
assert new_cluster.last_ts == 1_700_000_001_000
assert new_cluster.members == ["ac_20260517_0001"]
assert new_cluster.preview == ["summarise the doc"]
np.testing.assert_allclose(
np.asarray(new_cluster.centroid), np.array([0.1] * 1024, dtype=np.float32)
)
assert existing == []
# upsert called with the new cluster (since merge returned None).
upsert_args = mock_repo.upsert_with_members.call_args
persisted = upsert_args.args[0]
assert persisted.id == "cl_newxxxx0001"
assert upsert_args.kwargs == {
"owner_id": "agent_42",
"owner_type": "agent",
"kind": "agent_case",
"member_type": "case",
"app_id": "default",
"project_id": "default",
}
emitted = [e for e in ctx.emitted if isinstance(e, SkillClusterUpdated)]
assert len(emitted) == 1
assert emitted[0].cluster_id == "cl_newxxxx0001"
assert emitted[0].case_entry_id == "ac_20260517_0001"
assert emitted[0].agent_id == "agent_42"
async def test_merges_into_existing_cluster_when_algo_matches() -> None:
"""algo returns a merged Cluster → persisted with the existing id."""
embedder = MagicMock()
embedder.embed = AsyncMock(return_value=[0.2] * 1024)
ctx = FakeStrategyContext()
existing_cluster = AlgoCluster(
id="cl_existing0001",
centroid=np.array([0.15] * 1024, dtype=np.float32),
count=2,
last_ts=1_700_000_000_000,
preview=["earlier intent"],
members=["ac_20260517_0000"],
)
# Simulate evercore _merge: id passes through from existing, members appended.
merged_cluster = AlgoCluster(
id="cl_existing0001",
centroid=np.array([0.17] * 1024, dtype=np.float32),
count=3,
last_ts=1_700_000_001_000,
preview=["earlier intent", "summarise the doc"],
members=["ac_20260517_0000", "ac_20260517_0001"],
)
with (
patch(
"everos.memory.strategies.trigger_skill_clustering.get_embedder",
return_value=embedder,
),
patch(
"everos.memory.strategies.trigger_skill_clustering.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.trigger_skill_clustering.cluster_repo"
) as mock_repo,
patch(
"everos.memory.strategies.trigger_skill_clustering.cluster_by_llm",
new=AsyncMock(return_value=merged_cluster),
),
):
mock_repo.list_for_owner = AsyncMock(return_value=[existing_cluster])
mock_repo.upsert_with_members = AsyncMock(return_value=None)
await trigger_skill_clustering(_event(), ctx)
upsert_args = mock_repo.upsert_with_members.call_args
persisted = upsert_args.args[0]
assert persisted.id == "cl_existing0001"
assert persisted.members == ["ac_20260517_0000", "ac_20260517_0001"]
assert persisted.count == 3
emitted = [e for e in ctx.emitted if isinstance(e, SkillClusterUpdated)]
assert len(emitted) == 1
assert emitted[0].cluster_id == "cl_existing0001"
# ── partition lock (agent_id-level serialisation) ────────────────────────
async def _run_serialisation_probe(agent_a: str, agent_b: str) -> list[str]:
"""Drive two trigger_skill_clustering runs and record entry/exit order.
The clustering LLM call is the only awaited work inside the locked
region — replacing it with a tiny ``asyncio.sleep`` keeps the test
fast while still proving the lock either does or does not interleave
the two critical sections.
"""
log: list[str] = []
async def mock_cluster_by_llm(new_cluster, _existing, **_kwargs):
log.append(f"enter:{new_cluster.members[0]}")
await asyncio.sleep(0.01)
log.append(f"leave:{new_cluster.members[0]}")
return None # no merge → caller persists the size-1 cluster
mock_embedder = MagicMock()
mock_embedder.embed = AsyncMock(return_value=np.zeros(1024, dtype=np.float32))
with (
patch(
"everos.memory.strategies.trigger_skill_clustering.get_embedder",
return_value=mock_embedder,
),
patch(
"everos.memory.strategies.trigger_skill_clustering.get_llm_client",
return_value=object(),
),
patch(
"everos.memory.strategies.trigger_skill_clustering.cluster_repo"
) as mock_repo,
patch(
"everos.memory.strategies.trigger_skill_clustering.cluster_by_llm",
new=mock_cluster_by_llm,
),
):
mock_repo.list_for_owner = AsyncMock(return_value=[])
mock_repo.upsert_with_members = AsyncMock(return_value=None)
await asyncio.gather(
trigger_skill_clustering(
_event(agent_id=agent_a, case_entry_id="ac_run_a"),
FakeStrategyContext(),
),
trigger_skill_clustering(
_event(agent_id=agent_b, case_entry_id="ac_run_b"),
FakeStrategyContext(),
),
)
return log
async def test_partition_lock_serialises_runs_on_same_agent() -> None:
"""Two runs sharing ``agent_id`` must not overlap critical sections."""
log = await _run_serialisation_probe("agent_42", "agent_42")
assert log in (
["enter:ac_run_a", "leave:ac_run_a", "enter:ac_run_b", "leave:ac_run_b"],
["enter:ac_run_b", "leave:ac_run_b", "enter:ac_run_a", "leave:ac_run_a"],
)
async def test_partition_lock_lets_different_agents_run_in_parallel() -> None:
"""Runs on distinct ``agent_id`` must overlap (no false serialisation)."""
log = await _run_serialisation_probe("agent_42", "agent_43")
assert log.index("enter:ac_run_a") < log.index("leave:ac_run_b")
assert log.index("enter:ac_run_b") < log.index("leave:ac_run_a")