Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/everos/memory/strategies/_sender_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Shared sender-collection helpers for strategy modules."""

from __future__ import annotations

from everalgo.types import MemCell


def collect_user_sender_ids(memcell: MemCell) -> list[str]:
"""Distinct ``role='user'`` sender_ids in stable sorted order.

User-side strategies may receive agent-trajectory cells that include
``ToolCallRequest`` items alongside chat messages. Those items expose
``sender_id`` but not ``role``, so sender discovery must probe the
attribute defensively instead of assuming every item is a ChatMessage.
"""

sender_ids: set[str] = set()
for item in memcell.items:
if getattr(item, "role", None) != "user":
continue
sid = getattr(item, "sender_id", None)
if sid:
sender_ids.add(sid)
return sorted(sender_ids)
3 changes: 2 additions & 1 deletion src/everos/memory/strategies/extract_atomic_facts.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from everos.infra.persistence.markdown import AtomicFactWriter
from everos.memory.events import UserPipelineStarted
from everos.memory.models import AtomicFact
from everos.memory.strategies._sender_utils import collect_user_sender_ids

logger = get_logger(__name__)

Expand All @@ -63,7 +64,7 @@ async def extract_atomic_facts(
) -> None:
# 1. List the user senders in this memcell; bail early if there are none.
memcell = event.memcell
sender_ids = sorted({m.sender_id for m in memcell.items if m.role == "user"})
sender_ids = collect_user_sender_ids(memcell)
if not sender_ids:
logger.info(
"atomic_facts_extracted",
Expand Down
3 changes: 2 additions & 1 deletion src/everos/memory/strategies/extract_foresight.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from everos.infra.persistence.markdown import ForesightWriter
from everos.memory.events import UserPipelineStarted
from everos.memory.models import Foresight
from everos.memory.strategies._sender_utils import collect_user_sender_ids

logger = get_logger(__name__)

Expand All @@ -51,7 +52,7 @@ def _get_writer() -> ForesightWriter:
async def extract_foresight(event: UserPipelineStarted, ctx: StrategyContext) -> None:
# 1. List the user senders in this memcell.
memcell = event.memcell
sender_ids = sorted({m.sender_id for m in memcell.items if m.role == "user"})
sender_ids = collect_user_sender_ids(memcell)
extractor = ForesightExtractor(llm=get_llm_client()) if sender_ids else None

# 2. Run the LLM extractor once per sender (prompt is per-sender).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,14 @@

import anyio
import pytest
from everalgo.types import AgentCase, AtomicFact, ChatMessage, Foresight, MemCell
from everalgo.types import (
AgentCase,
AtomicFact,
ChatMessage,
Foresight,
MemCell,
ToolCallRequest,
)

from everos.component.embedding import EmbeddingProvider
from everos.component.tokenizer import Tokenizer
Expand Down Expand Up @@ -75,6 +82,37 @@ def _event(owner_id: str) -> UserPipelineStarted:
)


def _event_with_tool_call_request(owner_id: str) -> UserPipelineStarted:
return UserPipelineStarted(
memcell_id="mc_tool_call",
session_id="s1",
memcell=MemCell(
items=[
ChatMessage(
id="m1",
role="user",
content="hi",
timestamp=1_700_000_000_000,
sender_id=owner_id,
),
ToolCallRequest(
tool_calls=[
{
"id": "tc1",
"type": "function",
"function": {"name": "search", "arguments": "{}"},
}
],
content="calling tool",
timestamp=1_700_000_001_000,
sender_id="agent_42",
),
],
timestamp=1_700_000_001_000,
),
)


async def _build_row_from_md(
handler: AtomicFactHandler | ForesightHandler | AgentCaseHandler,
md_root: Path,
Expand Down Expand Up @@ -199,6 +237,96 @@ async def test_foresight_strategy_md_feeds_handler_with_content(
assert len(row.vector) == 1024


async def test_atomic_fact_strategy_accepts_tool_call_request_items(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""User-side extraction skips tool-call items that do not expose ``role``."""
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_with_tool_call_request("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"
)
assert row.fact == "alice likes hiking"


async def test_foresight_strategy_accepts_tool_call_request_items(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""User-side extraction skips tool-call items that do not expose ``role``."""
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_with_tool_call_request("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"
)
assert row.foresight == "plans trip to tokyo"
assert row.evidence == "said so explicitly"


def _agent_event() -> AgentPipelineStarted:
return AgentPipelineStarted(
memcell_id="mc_a",
Expand Down