From 70c59dfa0cf327a6ab181dec852e45def465ae39 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Fri, 15 May 2026 23:07:22 +0200 Subject: [PATCH] fix mcp recall daemon performance --- src/superlocalmemory/core/recall_worker.py | 8 +- src/superlocalmemory/core/worker_pool.py | 3 +- src/superlocalmemory/mcp/_daemon_proxy.py | 10 +- src/superlocalmemory/mcp/_pool_adapter.py | 9 +- src/superlocalmemory/mcp/tools_active.py | 49 +-- src/superlocalmemory/mcp/tools_core.py | 11 +- src/superlocalmemory/mcp/tools_v3.py | 4 +- src/superlocalmemory/storage/schema.py | 6 +- tests/test_core/test_worker_pool_fast.py | 29 ++ tests/test_mcp/test_mcp_daemon_proxy.py | 24 +- tests/test_mcp/test_mcp_pool_adapter.py | 28 +- tests/test_mcp/test_mcp_recall_tool.py | 55 +++- tests/test_mcp/test_mcp_session_init_tool.py | 308 +++++-------------- tests/test_storage/test_schema.py | 14 + 14 files changed, 266 insertions(+), 292 deletions(-) create mode 100644 tests/test_core/test_worker_pool_fast.py diff --git a/src/superlocalmemory/core/recall_worker.py b/src/superlocalmemory/core/recall_worker.py index 6549a78c..a580913f 100644 --- a/src/superlocalmemory/core/recall_worker.py +++ b/src/superlocalmemory/core/recall_worker.py @@ -59,10 +59,12 @@ def _get_engine(): return _engine -def _handle_recall(query: str, limit: int, session_id: str = "") -> dict: +def _handle_recall( + query: str, limit: int, session_id: str = "", fast: bool = False, +) -> dict: engine = _get_engine() response = engine.recall( - query, limit=limit, session_id=session_id or None, + query, limit=limit, session_id=session_id or None, fast=bool(fast), ) # Batch-fetch original memory text for all results @@ -290,7 +292,7 @@ def _worker_main() -> None: if cmd == "recall": result = _handle_recall( req.get("query", ""), req.get("limit", 10), - req.get("session_id", ""), + req.get("session_id", ""), bool(req.get("fast", False)), ) _respond(result) elif cmd == "store": diff --git a/src/superlocalmemory/core/worker_pool.py b/src/superlocalmemory/core/worker_pool.py index 945ed20b..5ded40be 100644 --- a/src/superlocalmemory/core/worker_pool.py +++ b/src/superlocalmemory/core/worker_pool.py @@ -67,6 +67,7 @@ def shared(cls) -> WorkerPool: def recall( self, query: str, limit: int = 10, session_id: str = "", + fast: bool = False, ) -> dict: """Run recall in worker subprocess. Returns result dict. @@ -77,7 +78,7 @@ def recall( """ return self._send({ "cmd": "recall", "query": query, "limit": limit, - "session_id": session_id or "", + "session_id": session_id or "", "fast": bool(fast), }) def store(self, content: str, metadata: dict | None = None) -> dict: diff --git a/src/superlocalmemory/mcp/_daemon_proxy.py b/src/superlocalmemory/mcp/_daemon_proxy.py index 9c52d6af..c351b6d0 100644 --- a/src/superlocalmemory/mcp/_daemon_proxy.py +++ b/src/superlocalmemory/mcp/_daemon_proxy.py @@ -45,10 +45,14 @@ def _url(self, path: str) -> str: def recall( self, query: str, limit: int = 10, session_id: str = "", + fast: bool = False, ) -> dict[str, Any]: - params = urllib.parse.urlencode( - {"q": query, "limit": limit, "session_id": session_id or ""} - ) + params = urllib.parse.urlencode({ + "q": query, + "limit": limit, + "session_id": session_id or "", + "fast": "true" if fast else "false", + }) try: with urllib.request.urlopen( self._url(f"/recall?{params}"), timeout=self._timeout, diff --git a/src/superlocalmemory/mcp/_pool_adapter.py b/src/superlocalmemory/mcp/_pool_adapter.py index ae685939..72840d45 100644 --- a/src/superlocalmemory/mcp/_pool_adapter.py +++ b/src/superlocalmemory/mcp/_pool_adapter.py @@ -74,12 +74,17 @@ def _unwrap_error(raw: Any, op: str) -> None: raise PoolError(f"pool.{op} failed: {reason}") -def pool_recall(query: str, limit: int = 10, **_: Any) -> PoolRecallResponse: +def pool_recall(query: str, limit: int = 10, **kwargs: Any) -> PoolRecallResponse: """Call pool.recall and reshape its dict into a typed response. Raises :class:`PoolError` on worker death or any non-ok envelope. """ - raw = _pool().recall(query=query, limit=limit) + raw = _pool().recall( + query=query, + limit=limit, + session_id=str(kwargs.get("session_id") or ""), + fast=bool(kwargs.get("fast", False)), + ) _unwrap_error(raw, "recall") items = raw.get("results", []) if isinstance(raw, dict) else [] results = [ diff --git a/src/superlocalmemory/mcp/tools_active.py b/src/superlocalmemory/mcp/tools_active.py index 30679434..78cabb67 100644 --- a/src/superlocalmemory/mcp/tools_active.py +++ b/src/superlocalmemory/mcp/tools_active.py @@ -93,7 +93,6 @@ async def session_init( The AI should call this automatically before any other work. """ try: - from superlocalmemory.hooks.auto_recall import AutoRecall from superlocalmemory.hooks.rules_engine import RulesEngine from superlocalmemory.mcp._pool_adapter import pool_recall @@ -104,21 +103,37 @@ async def session_init( return {"success": True, "context": "", "memories": [], "message": "Auto-recall disabled"} recall_config = rules.get_recall_config() - auto = AutoRecall( - recall_fn=pool_recall, - config={ - "enabled": True, - "max_memories_injected": max_results, - "relevance_threshold": recall_config.get("relevance_threshold", 0.3), - }, - ) - - # Get formatted context for system prompt injection - context = auto.get_session_context(project_path=project_path, query=query) - - # Get structured results for tool response - search_query = query or f"project context {project_path}" if project_path else "recent important decisions" - memories = auto.get_query_context(search_query) + relevance_threshold = recall_config.get("relevance_threshold", 0.3) + if query: + search_query = query + elif project_path: + search_query = f"project context {project_path}" + else: + search_query = "recent important decisions" + + response = pool_recall(search_query, limit=max_results, fast=True) + relevant = [ + r for r in response.results + if r.score >= relevance_threshold + ] + + # Build both return shapes from one recall. Calling recall twice + # doubles session startup latency and can return duplicate snippets. + context = "" + if relevant: + lines = ["# Relevant Memory Context", ""] + for r in relevant[:max_results]: + lines.append(f"- {r.fact.content[:200]}") + context = "\n".join(lines) + + memories = [ + { + "fact_id": r.fact.fact_id, + "content": r.fact.content[:300], + "score": round(r.score, 3), + } + for r in relevant[:max_results] + ] # Get learning status pid = engine.profile_id @@ -184,7 +199,6 @@ async def observe( from superlocalmemory.hooks.rules_engine import RulesEngine from superlocalmemory.mcp._pool_adapter import pool_store - engine = get_engine() rules = RulesEngine() auto = AutoCapture( @@ -305,7 +319,6 @@ async def close_session(session_id: str = "") -> dict: """ try: engine = get_engine() - pid = engine.profile_id sid = session_id or getattr(engine, '_last_session_id', '') if not sid: return {"success": False, "error": "No session_id provided"} diff --git a/src/superlocalmemory/mcp/tools_core.py b/src/superlocalmemory/mcp/tools_core.py index 410ef8c0..8e27a33b 100644 --- a/src/superlocalmemory/mcp/tools_core.py +++ b/src/superlocalmemory/mcp/tools_core.py @@ -13,10 +13,9 @@ from __future__ import annotations -import json import logging from pathlib import Path -from typing import Any, Callable +from typing import Callable from mcp.types import ToolAnnotations @@ -111,7 +110,6 @@ async def remember( Extracts atomic facts, resolves entities, builds graph edges, and indexes for 4-channel retrieval. """ - import asyncio try: # v3.4.32: Store-first pattern. Write to pending.db and return # immediately. The daemon's pending-materializer thread drains @@ -141,7 +139,7 @@ async def remember( @server.tool(annotations=ToolAnnotations(readOnlyHint=True)) async def recall( query: str, limit: int = 10, agent_id: str = "mcp_client", - session_id: str = "", + session_id: str = "", fast: bool = False, ) -> dict: """Search memories by semantic query with 4-channel retrieval, RRF fusion, and reranking. @@ -153,8 +151,8 @@ async def recall( """ import asyncio try: - from superlocalmemory.core.worker_pool import WorkerPool - pool = WorkerPool.shared() + from superlocalmemory.mcp._daemon_proxy import choose_pool + pool = choose_pool() # S9-DASH-10: priority for session_id, so engagement # signals land on the right pending_outcome: # 1. Explicit ``session_id`` tool-call argument. @@ -199,6 +197,7 @@ async def recall( # block behind a single threading.Lock. See worker_pool.py. result = await asyncio.to_thread( pool.recall, query, limit=limit, session_id=effective_sid, + fast=bool(fast), ) if result.get("ok"): # Record implicit feedback: every returned result is a recall_hit diff --git a/src/superlocalmemory/mcp/tools_v3.py b/src/superlocalmemory/mcp/tools_v3.py index c674429b..f0d4bdad 100644 --- a/src/superlocalmemory/mcp/tools_v3.py +++ b/src/superlocalmemory/mcp/tools_v3.py @@ -285,8 +285,8 @@ async def recall_trace(query: str, limit: int = 10) -> dict: limit: Maximum results (default 10). """ try: - from superlocalmemory.core.worker_pool import WorkerPool - raw = WorkerPool.shared().recall(query=query, limit=limit) + from superlocalmemory.mcp._daemon_proxy import choose_pool + raw = choose_pool().recall(query=query, limit=limit) items = raw.get("results", []) if isinstance(raw, dict) else [] results = [] for item in items[:limit]: diff --git a/src/superlocalmemory/storage/schema.py b/src/superlocalmemory/storage/schema.py index 29a34fb9..7c3a8cc7 100644 --- a/src/superlocalmemory/storage/schema.py +++ b/src/superlocalmemory/storage/schema.py @@ -252,7 +252,7 @@ def _set_pragmas(conn: sqlite3.Connection) -> None: -- left by V2 migration. -- INSERT trigger -CREATE TRIGGER atomic_facts_fts_insert +CREATE TRIGGER IF NOT EXISTS atomic_facts_fts_insert AFTER INSERT ON atomic_facts BEGIN INSERT INTO atomic_facts_fts (rowid, fact_id, content) @@ -260,7 +260,7 @@ def _set_pragmas(conn: sqlite3.Connection) -> None: END; -- DELETE trigger -CREATE TRIGGER atomic_facts_fts_delete +CREATE TRIGGER IF NOT EXISTS atomic_facts_fts_delete AFTER DELETE ON atomic_facts BEGIN INSERT INTO atomic_facts_fts (atomic_facts_fts, rowid, fact_id, content) @@ -268,7 +268,7 @@ def _set_pragmas(conn: sqlite3.Connection) -> None: END; -- UPDATE trigger -CREATE TRIGGER atomic_facts_fts_update +CREATE TRIGGER IF NOT EXISTS atomic_facts_fts_update AFTER UPDATE OF content ON atomic_facts BEGIN INSERT INTO atomic_facts_fts (atomic_facts_fts, rowid, fact_id, content) diff --git a/tests/test_core/test_worker_pool_fast.py b/tests/test_core/test_worker_pool_fast.py new file mode 100644 index 00000000..9fc571f4 --- /dev/null +++ b/tests/test_core/test_worker_pool_fast.py @@ -0,0 +1,29 @@ +# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar +# Licensed under AGPL-3.0-or-later - see LICENSE file +# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com + +"""WorkerPool recall request shaping.""" + +from __future__ import annotations + + +def test_worker_pool_recall_forwards_fast_flag(monkeypatch): + from superlocalmemory.core.worker_pool import WorkerPool + + pool = WorkerPool() + sent = {} + + def _fake_send(payload): + sent.update(payload) + return {"ok": True} + + monkeypatch.setattr(pool, "_send", _fake_send) + + assert pool.recall("q", limit=3, session_id="s-1", fast=True) == {"ok": True} + assert sent == { + "cmd": "recall", + "query": "q", + "limit": 3, + "session_id": "s-1", + "fast": True, + } diff --git a/tests/test_mcp/test_mcp_daemon_proxy.py b/tests/test_mcp/test_mcp_daemon_proxy.py index 2c6819e6..214a9ad1 100644 --- a/tests/test_mcp/test_mcp_daemon_proxy.py +++ b/tests/test_mcp/test_mcp_daemon_proxy.py @@ -8,11 +8,10 @@ from __future__ import annotations import json -from types import SimpleNamespace import pytest -from superlocalmemory.mcp._daemon_proxy import DaemonPoolProxy, choose_pool +from superlocalmemory.mcp._daemon_proxy import DaemonPoolProxy from superlocalmemory.mcp._pool_adapter import ( PoolError, pool_recall, pool_store, ) @@ -21,7 +20,7 @@ class TestPoolErrorSurfacing: def test_pool_recall_raises_on_ok_false(self, monkeypatch): class _Dead: - def recall(self, query, limit=10, session_id=""): + def recall(self, query, limit=10, session_id="", fast=False): return {"ok": False, "error": "worker died"} from superlocalmemory.mcp import _pool_adapter monkeypatch.setattr(_pool_adapter, "_pool", lambda: _Dead()) @@ -41,7 +40,7 @@ def store(self, content, metadata=None): def test_pool_recall_success_does_not_raise(self, monkeypatch): class _Ok: - def recall(self, query, limit=10, session_id=""): + def recall(self, query, limit=10, session_id="", fast=False): return {"ok": True, "results": [], "query_type": "x"} from superlocalmemory.mcp import _pool_adapter monkeypatch.setattr(_pool_adapter, "_pool", lambda: _Ok()) @@ -73,6 +72,23 @@ def _fake_urlopen(req, timeout=30): assert "limit=3" in captured["url"] assert "session_id=s-1" in captured["url"] + def test_recall_forwards_fast_flag(self, monkeypatch): + captured = {} + + def _fake_urlopen(req, timeout=30): + captured["url"] = getattr(req, "full_url", req) + return _FakeResp(json.dumps({ + "ok": True, "results": [], "query_type": "semantic", + }).encode()) + + import superlocalmemory.mcp._daemon_proxy as mod + monkeypatch.setattr(mod.urllib.request, "urlopen", _fake_urlopen) + + proxy = DaemonPoolProxy(port=9999) + out = proxy.recall("fast path", fast=True) + assert out["ok"] is True + assert "fast=true" in captured["url"] + def test_store_forwards_http_post(self, monkeypatch): captured = {} diff --git a/tests/test_mcp/test_mcp_pool_adapter.py b/tests/test_mcp/test_mcp_pool_adapter.py index b430231a..eb4431a1 100644 --- a/tests/test_mcp/test_mcp_pool_adapter.py +++ b/tests/test_mcp/test_mcp_pool_adapter.py @@ -17,8 +17,11 @@ def __init__(self): self.recall_calls = [] self.store_calls = [] - def recall(self, query: str, limit: int = 10, session_id: str = ""): - self.recall_calls.append((query, limit)) + def recall( + self, query: str, limit: int = 10, session_id: str = "", + fast: bool = False, + ): + self.recall_calls.append((query, limit, session_id, fast)) return { "ok": True, "query": query, @@ -59,7 +62,16 @@ def test_pool_recall_reshapes_dict(self, monkeypatch): assert resp.results[0].fact.fact_id == "f1" assert resp.results[0].fact.content == "first memory content" assert resp.results[0].score == 0.91 - assert fake.recall_calls == [("hello", 5)] + assert fake.recall_calls == [("hello", 5, "", False)] + + def test_pool_recall_forwards_session_id_and_fast(self, monkeypatch): + from superlocalmemory.mcp import _pool_adapter + fake = _FakePool() + monkeypatch.setattr(_pool_adapter, "_pool", lambda: fake) + + _pool_adapter.pool_recall("hello", limit=5, session_id="s-1", fast=True) + + assert fake.recall_calls == [("hello", 5, "s-1", True)] def test_pool_store_returns_fact_ids(self, monkeypatch): from superlocalmemory.mcp import _pool_adapter @@ -75,7 +87,7 @@ def test_pool_recall_handles_empty_results(self, monkeypatch): from superlocalmemory.mcp import _pool_adapter class _Empty: - def recall(self, query, limit=10, session_id=""): + def recall(self, query, limit=10, session_id="", fast=False): return {"ok": True, "results": []} monkeypatch.setattr(_pool_adapter, "_pool", lambda: _Empty()) @@ -89,7 +101,7 @@ def test_pool_recall_raises_on_ok_false(self, monkeypatch): from superlocalmemory.mcp._pool_adapter import PoolError class _Dead: - def recall(self, query, limit=10, session_id=""): + def recall(self, query, limit=10, session_id="", fast=False): return {"ok": False, "error": "worker died"} monkeypatch.setattr(_pool_adapter, "_pool", lambda: _Dead()) @@ -170,9 +182,9 @@ def _wrap(fn): result = asyncio.run(registered["session_init"](project_path="/tmp/p")) assert result["success"] is True - # At least one recall landed through the pool adapter - assert fake_pool.recall_calls, \ - "session_init did not route through pool_recall" + assert fake_pool.recall_calls == [ + ("project context /tmp/p", 10, "", True), + ], "session_init should perform one fast recall through pool_recall" def test_observe_uses_pool_adapter_not_engine_store(self, monkeypatch): import asyncio diff --git a/tests/test_mcp/test_mcp_recall_tool.py b/tests/test_mcp/test_mcp_recall_tool.py index 1f9d5fd5..5b93dcd1 100644 --- a/tests/test_mcp/test_mcp_recall_tool.py +++ b/tests/test_mcp/test_mcp_recall_tool.py @@ -7,7 +7,7 @@ Covers: - Success path: recall returns results list - Failure path: pool error propagated - - WorkerPool.shared().recall() called with query + limit + - choose_pool().recall() called with query + limit + fast - Event emission on success - Implicit feedback recording (_record_recall_hits) - Edge cases: empty query, limit forwarded, feedback failure non-blocking @@ -23,6 +23,15 @@ import pytest +@pytest.fixture(autouse=True) +def _inline_to_thread(monkeypatch): + """Run asyncio.to_thread inline so these unit tests never spawn threads.""" + async def _run_inline(fn, *args, **kwargs): + return fn(*args, **kwargs) + + monkeypatch.setattr(asyncio, "to_thread", _run_inline) + + # --------------------------------------------------------------------------- # Helper # --------------------------------------------------------------------------- @@ -76,7 +85,7 @@ def test_recall_success_returns_results(self, mock_emit, mock_record): recall, _ = _get_recall_tool() - with patch("superlocalmemory.core.worker_pool.WorkerPool.shared", return_value=pool): + with patch("superlocalmemory.mcp._daemon_proxy.choose_pool", return_value=pool): result = asyncio.run(recall("tell me about Python")) assert result["success"] is True @@ -93,7 +102,7 @@ def test_recall_failure_returns_error(self, mock_emit, mock_record): recall, _ = _get_recall_tool() - with patch("superlocalmemory.core.worker_pool.WorkerPool.shared", return_value=pool): + with patch("superlocalmemory.mcp._daemon_proxy.choose_pool", return_value=pool): result = asyncio.run(recall("any query")) assert result["success"] is False @@ -101,7 +110,7 @@ def test_recall_failure_returns_error(self, mock_emit, mock_record): @patch("superlocalmemory.mcp.tools_core._record_recall_hits") @patch("superlocalmemory.mcp.tools_core._emit_event") - def test_recall_calls_worker_pool_recall(self, mock_emit, mock_record): + def test_recall_calls_pool_recall(self, mock_emit, mock_record): """pool.recall() is called with the query and limit.""" pool = MagicMock() pool.recall.return_value = { @@ -113,7 +122,7 @@ def test_recall_calls_worker_pool_recall(self, mock_emit, mock_record): # S9-DASH-10: registry lookup must return None in tests so the # final fallback ``mcp:`` is used. Without the patch # the test picks up a real live session from the CI/dev registry. - with patch("superlocalmemory.core.worker_pool.WorkerPool.shared", return_value=pool), \ + with patch("superlocalmemory.mcp._daemon_proxy.choose_pool", return_value=pool), \ patch("superlocalmemory.hooks.session_registry.lookup_by_parent", return_value=None), \ patch("superlocalmemory.hooks.session_registry.most_recent_active", return_value=None): asyncio.run(recall("architecture patterns", limit=5)) @@ -123,6 +132,28 @@ def test_recall_calls_worker_pool_recall(self, mock_emit, mock_record): # session_id when the caller doesn't supply one is ``mcp:``. pool.recall.assert_called_once_with( "architecture patterns", limit=5, session_id="mcp:mcp_client", + fast=False, + ) + + @patch("superlocalmemory.mcp.tools_core._record_recall_hits") + @patch("superlocalmemory.mcp.tools_core._emit_event") + def test_recall_forwards_fast_flag(self, mock_emit, mock_record): + """fast=True is forwarded to the selected pool implementation.""" + pool = MagicMock() + pool.recall.return_value = { + "ok": True, "results": [], "result_count": 0, "query_type": "semantic", + } + + recall, _ = _get_recall_tool() + + with patch("superlocalmemory.mcp._daemon_proxy.choose_pool", return_value=pool), \ + patch("superlocalmemory.hooks.session_registry.lookup_by_parent", return_value=None), \ + patch("superlocalmemory.hooks.session_registry.most_recent_active", return_value=None): + asyncio.run(recall("architecture patterns", limit=5, fast=True)) + + pool.recall.assert_called_once_with( + "architecture patterns", limit=5, session_id="mcp:mcp_client", + fast=True, ) @patch("superlocalmemory.mcp.tools_core._record_recall_hits") @@ -136,7 +167,7 @@ def test_recall_emits_memory_recalled_event(self, mock_emit, mock_record): recall, _ = _get_recall_tool() - with patch("superlocalmemory.core.worker_pool.WorkerPool.shared", return_value=pool): + with patch("superlocalmemory.mcp._daemon_proxy.choose_pool", return_value=pool): asyncio.run(recall("event check")) mock_emit.assert_called_once() @@ -159,7 +190,7 @@ def test_recall_records_implicit_feedback(self, mock_emit): recall, get_engine = _get_recall_tool() - with patch("superlocalmemory.core.worker_pool.WorkerPool.shared", return_value=pool), \ + with patch("superlocalmemory.mcp._daemon_proxy.choose_pool", return_value=pool), \ patch("superlocalmemory.mcp.tools_core._record_recall_hits") as mock_record: asyncio.run(recall("feedback query")) @@ -184,14 +215,14 @@ def test_recall_empty_query_handled(self, mock_emit, mock_record): recall, _ = _get_recall_tool() - with patch("superlocalmemory.core.worker_pool.WorkerPool.shared", return_value=pool), \ + with patch("superlocalmemory.mcp._daemon_proxy.choose_pool", return_value=pool), \ patch("superlocalmemory.hooks.session_registry.lookup_by_parent", return_value=None), \ patch("superlocalmemory.hooks.session_registry.most_recent_active", return_value=None): result = asyncio.run(recall("")) assert result["success"] is True pool.recall.assert_called_once_with( - "", limit=10, session_id="mcp:mcp_client", + "", limit=10, session_id="mcp:mcp_client", fast=False, ) @patch("superlocalmemory.mcp.tools_core._record_recall_hits") @@ -205,13 +236,13 @@ def test_recall_limit_forwarded(self, mock_emit, mock_record): recall, _ = _get_recall_tool() - with patch("superlocalmemory.core.worker_pool.WorkerPool.shared", return_value=pool), \ + with patch("superlocalmemory.mcp._daemon_proxy.choose_pool", return_value=pool), \ patch("superlocalmemory.hooks.session_registry.lookup_by_parent", return_value=None), \ patch("superlocalmemory.hooks.session_registry.most_recent_active", return_value=None): asyncio.run(recall("limit test", limit=5)) pool.recall.assert_called_once_with( - "limit test", limit=5, session_id="mcp:mcp_client", + "limit test", limit=5, session_id="mcp:mcp_client", fast=False, ) @patch("superlocalmemory.mcp.tools_core._emit_event") @@ -227,7 +258,7 @@ def test_recall_feedback_failure_non_blocking(self, mock_emit): recall, _ = _get_recall_tool() - with patch("superlocalmemory.core.worker_pool.WorkerPool.shared", return_value=pool), \ + with patch("superlocalmemory.mcp._daemon_proxy.choose_pool", return_value=pool), \ patch( "superlocalmemory.mcp.tools_core._record_recall_hits", side_effect=RuntimeError("feedback DB broken"), diff --git a/tests/test_mcp/test_mcp_session_init_tool.py b/tests/test_mcp/test_mcp_session_init_tool.py index 6dd9cecd..012a0d4c 100644 --- a/tests/test_mcp/test_mcp_session_init_tool.py +++ b/tests/test_mcp/test_mcp_session_init_tool.py @@ -2,30 +2,23 @@ # Licensed under AGPL-3.0-or-later - see LICENSE file # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com -"""Tests for the MCP `session_init` tool — Phase 0 Safety Net. +"""Tests for the MCP `session_init` tool. -Covers: - - Success path: returns context, memories, learning status - - project_path and query forwarding - - RulesEngine gating (should_recall=False -> empty) - - max_results limiting - - Agent registration and event emission - - Error path: exception -> success=False - -Part of Qualixar | Author: Varun Pratap Bhardwaj +The hot path should do one fast recall through the pool adapter and build +both the formatted context and structured memory list from that response. """ from __future__ import annotations import asyncio -from unittest.mock import MagicMock, patch, PropertyMock - -import pytest +from unittest.mock import MagicMock, patch +from superlocalmemory.mcp._pool_adapter import ( + PoolFact, + PoolRecallItem, + PoolRecallResponse, +) -# --------------------------------------------------------------------------- -# Helper -# --------------------------------------------------------------------------- class _MockServer: """Minimal mock that captures @server.tool() decorated functions.""" @@ -34,7 +27,6 @@ def __init__(self): self._tools: dict[str, object] = {} def tool(self, *args, **kwargs): - # v3.4.26 Phase 1: ignore ToolAnnotations kwargs. def decorator(fn): self._tools[fn.__name__] = fn return fn @@ -59,103 +51,89 @@ def _make_engine_mock(profile_id="default", feedback_count=10): return engine -def _make_auto_recall_mock(context="# Context", memories=None): - """Build an AutoRecall mock returning controlled data.""" - auto = MagicMock() - auto.get_session_context.return_value = context - auto.get_query_context.return_value = memories or [] - return auto - - -def _make_rules_mock(should_recall=True): +def _make_rules_mock(should_recall=True, threshold=0.3): """Build a RulesEngine mock.""" rules = MagicMock() rules.should_recall.return_value = should_recall rules.get_recall_config.return_value = { "enabled": True, - "relevance_threshold": 0.3, + "relevance_threshold": threshold, "max_memories_injected": 10, } return rules -# --------------------------------------------------------------------------- -# Tests: happy path -# --------------------------------------------------------------------------- +def _make_response(count: int = 2) -> PoolRecallResponse: + """Build a typed pool recall response.""" + return PoolRecallResponse(results=[ + PoolRecallItem( + fact=PoolFact( + fact_id=f"f-{i}", + content=f"memory {i} content", + memory_id=f"m-{i}", + ), + score=0.9 - (i * 0.1), + ) + for i in range(count) + ]) -class TestSessionInitTool: - """Core behavior of the session_init MCP tool.""" +class TestSessionInitTool: @patch("superlocalmemory.mcp.tools_active._emit_event") @patch("superlocalmemory.mcp.tools_active._register_agent") - @patch("superlocalmemory.mcp.tools_active.RulesEngine", create=True) - @patch("superlocalmemory.mcp.tools_active.AutoRecall", create=True) @patch("superlocalmemory.hooks.rules_engine.RulesEngine") - @patch("superlocalmemory.hooks.auto_recall.AutoRecall") - def test_session_init_returns_context( - self, MockAutoRecall, MockRulesEngine, - _ar_create, _re_create, mock_register, mock_emit, + @patch("superlocalmemory.mcp._pool_adapter.pool_recall") + def test_session_init_returns_context_and_memories_from_one_fast_recall( + self, mock_pool_recall, MockRulesEngine, mock_register, mock_emit, ): - """session_init returns success=True with a context string.""" engine = _make_engine_mock() - auto = _make_auto_recall_mock(context="# Relevant Memory Context") rules = _make_rules_mock() - - MockAutoRecall.return_value = auto MockRulesEngine.return_value = rules + mock_pool_recall.return_value = _make_response(2) session_init, get_engine = _get_session_init_tool() get_engine.return_value = engine - with patch("superlocalmemory.hooks.auto_recall.AutoRecall", return_value=auto), \ - patch("superlocalmemory.hooks.rules_engine.RulesEngine", return_value=rules): - result = asyncio.run(session_init()) + result = asyncio.run(session_init(project_path="/my/project")) assert result["success"] is True - assert "context" in result + assert "memory 0 content" in result["context"] + assert result["memory_count"] == 2 + assert len(result["memories"]) == 2 + mock_pool_recall.assert_called_once_with( + "project context /my/project", limit=10, fast=True, + ) @patch("superlocalmemory.mcp.tools_active._emit_event") @patch("superlocalmemory.mcp.tools_active._register_agent") @patch("superlocalmemory.hooks.rules_engine.RulesEngine") - @patch("superlocalmemory.hooks.auto_recall.AutoRecall") - def test_session_init_returns_memories( - self, MockAutoRecall, MockRulesEngine, mock_register, mock_emit, + @patch("superlocalmemory.mcp._pool_adapter.pool_recall") + def test_session_init_uses_query_override( + self, mock_pool_recall, MockRulesEngine, mock_register, mock_emit, ): - """session_init returns a memories list.""" - memories = [ - {"fact_id": "f-1", "content": "decision X", "score": 0.9}, - {"fact_id": "f-2", "content": "bug fix Y", "score": 0.8}, - ] engine = _make_engine_mock() - auto = _make_auto_recall_mock(memories=memories) - rules = _make_rules_mock() - - MockAutoRecall.return_value = auto - MockRulesEngine.return_value = rules + MockRulesEngine.return_value = _make_rules_mock() + mock_pool_recall.return_value = _make_response(1) session_init, get_engine = _get_session_init_tool() get_engine.return_value = engine - result = asyncio.run(session_init()) + asyncio.run(session_init(query="what is Q-CLAW")) - assert result["success"] is True - assert result["memory_count"] == 2 - assert len(result["memories"]) == 2 + mock_pool_recall.assert_called_once_with( + "what is Q-CLAW", limit=10, fast=True, + ) @patch("superlocalmemory.mcp.tools_active._emit_event") @patch("superlocalmemory.mcp.tools_active._register_agent") @patch("superlocalmemory.hooks.rules_engine.RulesEngine") - @patch("superlocalmemory.hooks.auto_recall.AutoRecall") + @patch("superlocalmemory.mcp._pool_adapter.pool_recall") def test_session_init_returns_learning_status( - self, MockAutoRecall, MockRulesEngine, mock_register, mock_emit, + self, mock_pool_recall, MockRulesEngine, mock_register, mock_emit, ): - """session_init returns learning.phase based on feedback count.""" engine = _make_engine_mock(feedback_count=75) - auto = _make_auto_recall_mock() - rules = _make_rules_mock() - - MockAutoRecall.return_value = auto - MockRulesEngine.return_value = rules + MockRulesEngine.return_value = _make_rules_mock() + mock_pool_recall.return_value = _make_response(0) session_init, get_engine = _get_session_init_tool() get_engine.return_value = engine @@ -165,76 +143,60 @@ def test_session_init_returns_learning_status( assert result["success"] is True learning = result["learning"] assert learning["feedback_signals"] == 75 - assert learning["phase"] == 2 # 50 <= 75 < 200 -> phase 2 + assert learning["phase"] == 2 assert learning["status"] == "learning" @patch("superlocalmemory.mcp.tools_active._emit_event") @patch("superlocalmemory.mcp.tools_active._register_agent") @patch("superlocalmemory.hooks.rules_engine.RulesEngine") - @patch("superlocalmemory.hooks.auto_recall.AutoRecall") - def test_session_init_uses_project_path( - self, MockAutoRecall, MockRulesEngine, mock_register, mock_emit, + @patch("superlocalmemory.mcp._pool_adapter.pool_recall") + def test_session_init_respects_max_results( + self, mock_pool_recall, MockRulesEngine, mock_register, mock_emit, ): - """project_path is forwarded to AutoRecall.get_session_context().""" engine = _make_engine_mock() - auto = _make_auto_recall_mock() - rules = _make_rules_mock() - - MockAutoRecall.return_value = auto - MockRulesEngine.return_value = rules + MockRulesEngine.return_value = _make_rules_mock() + mock_pool_recall.return_value = _make_response(20) session_init, get_engine = _get_session_init_tool() get_engine.return_value = engine - asyncio.run(session_init(project_path="/my/project")) + result = asyncio.run(session_init(max_results=3)) - auto.get_session_context.assert_called_once_with( - project_path="/my/project", query="", + assert result["success"] is True + assert len(result["memories"]) == 3 + mock_pool_recall.assert_called_once_with( + "recent important decisions", limit=3, fast=True, ) @patch("superlocalmemory.mcp.tools_active._emit_event") @patch("superlocalmemory.mcp.tools_active._register_agent") @patch("superlocalmemory.hooks.rules_engine.RulesEngine") - @patch("superlocalmemory.hooks.auto_recall.AutoRecall") - def test_session_init_uses_query_override( - self, MockAutoRecall, MockRulesEngine, mock_register, mock_emit, + @patch("superlocalmemory.mcp._pool_adapter.pool_recall") + def test_session_init_filters_by_relevance_threshold( + self, mock_pool_recall, MockRulesEngine, mock_register, mock_emit, ): - """Explicit query param is forwarded to AutoRecall.""" engine = _make_engine_mock() - auto = _make_auto_recall_mock() - rules = _make_rules_mock() - - MockAutoRecall.return_value = auto - MockRulesEngine.return_value = rules + MockRulesEngine.return_value = _make_rules_mock(threshold=0.85) + mock_pool_recall.return_value = _make_response(2) session_init, get_engine = _get_session_init_tool() get_engine.return_value = engine - asyncio.run(session_init(query="what is Q-CLAW")) - - auto.get_session_context.assert_called_once_with( - project_path="", query="what is Q-CLAW", - ) + result = asyncio.run(session_init()) + assert result["memory_count"] == 1 + assert result["memories"][0]["fact_id"] == "f-0" -# --------------------------------------------------------------------------- -# Tests: gating -# --------------------------------------------------------------------------- class TestSessionInitGating: - """RulesEngine gating for session_init.""" - @patch("superlocalmemory.mcp.tools_active._emit_event") @patch("superlocalmemory.mcp.tools_active._register_agent") @patch("superlocalmemory.hooks.rules_engine.RulesEngine") - @patch("superlocalmemory.hooks.auto_recall.AutoRecall") def test_session_init_disabled_by_rules( - self, MockAutoRecall, MockRulesEngine, mock_register, mock_emit, + self, MockRulesEngine, mock_register, mock_emit, ): - """When should_recall returns False, response is empty context.""" engine = _make_engine_mock() - rules = _make_rules_mock(should_recall=False) - MockRulesEngine.return_value = rules + MockRulesEngine.return_value = _make_rules_mock(should_recall=False) session_init, get_engine = _get_session_init_tool() get_engine.return_value = engine @@ -246,150 +208,36 @@ def test_session_init_disabled_by_rules( assert result["memories"] == [] assert "disabled" in result["message"].lower() - @patch("superlocalmemory.mcp.tools_active._emit_event") - @patch("superlocalmemory.mcp.tools_active._register_agent") - @patch("superlocalmemory.hooks.rules_engine.RulesEngine") - @patch("superlocalmemory.hooks.auto_recall.AutoRecall") - def test_session_init_respects_max_results( - self, MockAutoRecall, MockRulesEngine, mock_register, mock_emit, - ): - """max_results limits how many memories are returned.""" - many_memories = [ - {"fact_id": f"f-{i}", "content": f"mem {i}", "score": 0.9} - for i in range(20) - ] - engine = _make_engine_mock() - auto = _make_auto_recall_mock(memories=many_memories) - rules = _make_rules_mock() - - MockAutoRecall.return_value = auto - MockRulesEngine.return_value = rules - - session_init, get_engine = _get_session_init_tool() - get_engine.return_value = engine - - result = asyncio.run(session_init(max_results=3)) - - assert result["success"] is True - # Memories are sliced to max_results - assert len(result["memories"]) <= 3 - - -# --------------------------------------------------------------------------- -# Tests: integration-like (agent registration, events, errors) -# --------------------------------------------------------------------------- class TestSessionInitIntegration: - """Agent registration, event emission, and error handling.""" - - @patch("superlocalmemory.mcp.tools_active._emit_event") - @patch("superlocalmemory.mcp.tools_active._register_agent") - @patch("superlocalmemory.hooks.rules_engine.RulesEngine") - @patch("superlocalmemory.hooks.auto_recall.AutoRecall") - def test_session_init_registers_agent_default( - self, MockAutoRecall, MockRulesEngine, mock_register, mock_emit, - monkeypatch, - ): - """_register_agent uses 'mcp_client' default when SLM_AGENT_ID is unset.""" - monkeypatch.delenv("SLM_AGENT_ID", raising=False) - engine = _make_engine_mock(profile_id="varun") - auto = _make_auto_recall_mock() - rules = _make_rules_mock() - - MockAutoRecall.return_value = auto - MockRulesEngine.return_value = rules - - session_init, get_engine = _get_session_init_tool() - get_engine.return_value = engine - - asyncio.run(session_init()) - - mock_register.assert_called_once_with("mcp_client", "varun") - @patch("superlocalmemory.mcp.tools_active._emit_event") @patch("superlocalmemory.mcp.tools_active._register_agent") @patch("superlocalmemory.hooks.rules_engine.RulesEngine") - @patch("superlocalmemory.hooks.auto_recall.AutoRecall") + @patch("superlocalmemory.mcp._pool_adapter.pool_recall") def test_session_init_registers_agent_from_env( - self, MockAutoRecall, MockRulesEngine, mock_register, mock_emit, + self, mock_pool_recall, MockRulesEngine, mock_register, mock_emit, monkeypatch, ): - """v3.4.39: SLM_AGENT_ID env overrides default for proper per-agent attribution.""" monkeypatch.setenv("SLM_AGENT_ID", "codex") engine = _make_engine_mock(profile_id="varun") - auto = _make_auto_recall_mock() - rules = _make_rules_mock() - - MockAutoRecall.return_value = auto - MockRulesEngine.return_value = rules - - session_init, get_engine = _get_session_init_tool() - get_engine.return_value = engine - - asyncio.run(session_init()) - - mock_register.assert_called_once_with("codex", "varun") - - @patch("superlocalmemory.mcp.tools_active._emit_event") - @patch("superlocalmemory.mcp.tools_active._register_agent") - @patch("superlocalmemory.hooks.rules_engine.RulesEngine") - @patch("superlocalmemory.hooks.auto_recall.AutoRecall") - def test_session_init_emits_agent_connected( - self, MockAutoRecall, MockRulesEngine, mock_register, mock_emit, - monkeypatch, - ): - """Event 'agent.connected' is emitted with project_path and resolved agent_id.""" - monkeypatch.delenv("SLM_AGENT_ID", raising=False) - engine = _make_engine_mock() - auto = _make_auto_recall_mock() - rules = _make_rules_mock() - - MockAutoRecall.return_value = auto - MockRulesEngine.return_value = rules + MockRulesEngine.return_value = _make_rules_mock() + mock_pool_recall.return_value = _make_response(1) session_init, get_engine = _get_session_init_tool() get_engine.return_value = engine asyncio.run(session_init(project_path="/slm")) + mock_register.assert_called_once_with("codex", "varun") mock_emit.assert_called_once() - args = mock_emit.call_args - assert args[0][0] == "agent.connected" - payload = args[0][1] + payload = mock_emit.call_args[0][1] + assert payload["agent_id"] == "codex" assert payload["project_path"] == "/slm" - assert payload["agent_id"] == "mcp_client" - - @patch("superlocalmemory.mcp.tools_active._emit_event") - @patch("superlocalmemory.mcp.tools_active._register_agent") - @patch("superlocalmemory.hooks.rules_engine.RulesEngine") - @patch("superlocalmemory.hooks.auto_recall.AutoRecall") - def test_session_init_emits_agent_connected_from_env( - self, MockAutoRecall, MockRulesEngine, mock_register, mock_emit, - monkeypatch, - ): - """v3.4.39: 'agent.connected' payload carries SLM_AGENT_ID env value.""" - monkeypatch.setenv("SLM_AGENT_ID", "gemini") - engine = _make_engine_mock() - auto = _make_auto_recall_mock() - rules = _make_rules_mock() - - MockAutoRecall.return_value = auto - MockRulesEngine.return_value = rules - - session_init, get_engine = _get_session_init_tool() - get_engine.return_value = engine - - asyncio.run(session_init(project_path="/slm")) - - mock_emit.assert_called_once() - args = mock_emit.call_args - payload = args[0][1] - assert payload["agent_id"] == "gemini" + assert payload["memory_count"] == 1 @patch("superlocalmemory.mcp.tools_active._emit_event") @patch("superlocalmemory.mcp.tools_active._register_agent") def test_session_init_error_returns_failure(self, mock_register, mock_emit): - """When get_engine raises, tool returns success=False with error.""" session_init, get_engine = _get_session_init_tool() get_engine.side_effect = RuntimeError("engine init failed") diff --git a/tests/test_storage/test_schema.py b/tests/test_storage/test_schema.py index 4c5690b4..ca46b4a6 100644 --- a/tests/test_storage/test_schema.py +++ b/tests/test_storage/test_schema.py @@ -77,6 +77,20 @@ def test_idempotent_creation(self, conn: sqlite3.Connection) -> None: conn.commit() # If we get here without error, the IF NOT EXISTS works. + def test_fts_triggers_are_idempotent(self) -> None: + """FTS trigger DDL must tolerate concurrent/repeated schema init.""" + from superlocalmemory.storage import schema + + assert "CREATE TRIGGER IF NOT EXISTS atomic_facts_fts_insert" in ( + schema._SQL_ATOMIC_FACTS_FTS + ) + assert "CREATE TRIGGER IF NOT EXISTS atomic_facts_fts_delete" in ( + schema._SQL_ATOMIC_FACTS_FTS + ) + assert "CREATE TRIGGER IF NOT EXISTS atomic_facts_fts_update" in ( + schema._SQL_ATOMIC_FACTS_FTS + ) + def test_schema_version_seeded(self, conn: sqlite3.Connection) -> None: row = conn.execute("SELECT version FROM schema_version").fetchone() assert row is not None