diff --git a/src/everos/memory/strategies/_sender_utils.py b/src/everos/memory/strategies/_sender_utils.py new file mode 100644 index 000000000..a52c53237 --- /dev/null +++ b/src/everos/memory/strategies/_sender_utils.py @@ -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) diff --git a/src/everos/memory/strategies/extract_atomic_facts.py b/src/everos/memory/strategies/extract_atomic_facts.py index a1f7458d9..a60bcb658 100644 --- a/src/everos/memory/strategies/extract_atomic_facts.py +++ b/src/everos/memory/strategies/extract_atomic_facts.py @@ -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__) @@ -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", diff --git a/src/everos/memory/strategies/extract_foresight.py b/src/everos/memory/strategies/extract_foresight.py index 18ac5a531..770db022b 100644 --- a/src/everos/memory/strategies/extract_foresight.py +++ b/src/everos/memory/strategies/extract_foresight.py @@ -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__) @@ -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). diff --git a/tests/unit/test_memory/test_strategies/test_strategy_to_handler_contract.py b/tests/unit/test_memory/test_strategies/test_strategy_to_handler_contract.py index 7abd076f4..46ceab178 100644 --- a/tests/unit/test_memory/test_strategies/test_strategy_to_handler_contract.py +++ b/tests/unit/test_memory/test_strategies/test_strategy_to_handler_contract.py @@ -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 @@ -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, @@ -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",