Merge pull request 'feat: same-turn memory retrieval in provider mode' (#6) from feature/same-turn-prefetch into main

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2026-08-15 17:53:03 +00:00
4 changed files with 64 additions and 25 deletions
+2 -2
View File
@@ -43,8 +43,8 @@ curl http://localhost:8000/health
**Memory injection flow:** **Memory injection flow:**
1. `queue_prefetch()` — Spawns background thread on user message 1. `queue_prefetch()` — Spawns background thread on user message
2. `prefetch()`Returns cached result next turn 2. `prefetch()`Sync retrieval for the current query (same-turn injection); falls back to cached background result when no query is provided
3. `pre_llm_call` hook — Sync prefetch for immediate injection (fallback context) 3. `pre_llm_call` hook — Sync prefetch for immediate injection (plugin mode)
4. `sync_turn()` — Non-blocking server-side fact extraction 4. `sync_turn()` — Non-blocking server-side fact extraction
**Circuit breaker:** 5 consecutive failures → 120s cooldown. **Circuit breaker:** 5 consecutive failures → 120s cooldown.
+5 -6
View File
@@ -5,7 +5,7 @@ Self-hosted Mem0 memory provider for Hermes-Agent. Provides semantic memory sear
## Features ## Features
- **Local Mem0 server** — No cloud dependency, full data privacy - **Local Mem0 server** — No cloud dependency, full data privacy
- **Async prefetch** — Memory retrieval happens in background (~40ms) - **Same-turn prefetch** — Memory retrieved synchronously before the LLM call (~40ms)
- **Context injection** — Relevant memories injected directly into LLM prompt - **Context injection** — Relevant memories injected directly into LLM prompt
- **Automatic fact extraction** — Server-side LLM extracts facts from conversations - **Automatic fact extraction** — Server-side LLM extracts facts from conversations
- **Semantic search** — Find memories by meaning, not keywords - **Semantic search** — Find memories by meaning, not keywords
@@ -138,16 +138,15 @@ hermes gateway restart
### How It Works ### How It Works
1. **User message received** → `queue_prefetch()` spawns background thread 1. **User message received** → `prefetch()` searches Mem0 synchronously (~40ms)
2. **Mem0 search** → Semantic search for relevant memories (~40ms) 2. **Context injection** → Results injected before the LLM call
3. **Context injection** → Results injected via `pre_llm_call` hook 3. **LLM receives** → User message + memory context (no tool call needed!)
4. **LLM receives** → User message + memory context (no tool call needed!)
**Example**: **Example**:
``` ```
User: "Hey, is a new episode out from my favorite anime?" User: "Hey, is a new episode out from my favorite anime?"
↓ [Background: mem0.prefetch() searches for "favorite anime"] ↓ [mem0.prefetch() searches for "favorite anime"]
LLM receives: LLM receives:
""" """
+46 -11
View File
@@ -227,6 +227,7 @@ class Mem0LocalMemoryProvider(MemoryProvider):
], ],
} }
self._prefetch_result = "" self._prefetch_result = ""
self._prefetch_query = ""
self._prefetch_lock = threading.Lock() self._prefetch_lock = threading.Lock()
self._prefetch_thread = None self._prefetch_thread = None
self._sync_thread = None self._sync_thread = None
@@ -520,20 +521,39 @@ class Mem0LocalMemoryProvider(MemoryProvider):
) )
def prefetch(self, query: str = "", *, session_id: str = "") -> str: def prefetch(self, query: str = "", *, session_id: str = "") -> str:
"""Return cached prefetch result from previous turn. """Return memory context for the current turn.
Reuses the in-flight background prefetch when it was started for the
same query, otherwise performs a synchronous search (bounded by the
shorter of the configured client timeout and 3s) so memories are
injected on the same turn. Falls back to the cached background
result when no query is available.
Args: Args:
query: Deprecated, kept for API compatibility. query: Current user message, used for synchronous retrieval.
session_id: Session identifier. session_id: Session identifier.
""" """
if self._prefetch_thread and self._prefetch_thread.is_alive(): if query:
self._prefetch_thread.join(timeout=3.0) with self._prefetch_lock:
with self._prefetch_lock: query_matches = self._prefetch_query == query
result = self._prefetch_result if query_matches and self._prefetch_thread and self._prefetch_thread.is_alive():
self._prefetch_result = "" # Background search for this exact query is in flight — wait for it
self._prefetch_thread.join(timeout=3.0)
if query_matches:
with self._prefetch_lock:
result = self._prefetch_result
self._prefetch_result = ""
else:
# No cached result for this query — search synchronously
result = self.queue_prefetch_and_get(query)
else:
if self._prefetch_thread and self._prefetch_thread.is_alive():
self._prefetch_thread.join(timeout=3.0)
with self._prefetch_lock:
result = self._prefetch_result
self._prefetch_result = ""
if not result: if not result:
return "" return ""
# Check if it's an error message
if result.startswith("ERROR:"): if result.startswith("ERROR:"):
return f"<mem0_error>\n{result[6:]}\n</mem0_error>" return f"<mem0_error>\n{result[6:]}\n</mem0_error>"
return f"<mem0_context>\n{result}\n</mem0_context>" return f"<mem0_context>\n{result}\n</mem0_context>"
@@ -570,11 +590,17 @@ class Mem0LocalMemoryProvider(MemoryProvider):
return "" return ""
try: try:
client = self._get_client() client = self._get_client()
# Cap total blocking on the LLM hot path at ~3s
prefetch_timeout = min(client.timeout, 3.0)
if self._case_insensitive:
# case-insensitive search runs two sequential requests
prefetch_timeout /= 2
results = client.search( results = client.search(
query=query, query=query,
user_id=self._user_id, user_id=self._user_id,
limit=self._prefetch_limit, limit=self._prefetch_limit,
case_insensitive=self._case_insensitive, case_insensitive=self._case_insensitive,
timeout=prefetch_timeout,
) )
# Filter by score threshold # Filter by score threshold
threshold = self._prefetch_score_threshold / 100.0 threshold = self._prefetch_score_threshold / 100.0
@@ -602,14 +628,20 @@ class Mem0LocalMemoryProvider(MemoryProvider):
""" """
if self._is_breaker_open(): if self._is_breaker_open():
with self._prefetch_lock: with self._prefetch_lock:
self._prefetch_query = query
self._prefetch_result = "ERROR:Memory service temporarily unavailable. Please try again later." self._prefetch_result = "ERROR:Memory service temporarily unavailable. Please try again later."
return return
if self._is_trivial_prompt(query): if self._is_trivial_prompt(query):
with self._prefetch_lock: with self._prefetch_lock:
self._prefetch_query = query
self._prefetch_result = "" self._prefetch_result = ""
return return
with self._prefetch_lock:
self._prefetch_query = query
self._prefetch_result = ""
def _run(): def _run():
try: try:
client = self._get_client() client = self._get_client()
@@ -625,16 +657,19 @@ class Mem0LocalMemoryProvider(MemoryProvider):
if filtered: if filtered:
formatted = self._format_search_results(filtered, categorize=self._categorize_enabled) formatted = self._format_search_results(filtered, categorize=self._categorize_enabled)
with self._prefetch_lock: with self._prefetch_lock:
self._prefetch_result = formatted if self._prefetch_query == query:
self._prefetch_result = formatted
else: else:
with self._prefetch_lock: with self._prefetch_lock:
self._prefetch_result = "" if self._prefetch_query == query:
self._prefetch_result = ""
self._record_success() self._record_success()
except Exception as e: except Exception as e:
self._record_failure() self._record_failure()
logger.debug("Mem0 prefetch failed: %s", e) logger.debug("Mem0 prefetch failed: %s", e)
with self._prefetch_lock: with self._prefetch_lock:
self._prefetch_result = "ERROR:Memory service temporarily unavailable. Please try again later." if self._prefetch_query == query:
self._prefetch_result = "ERROR:Memory service temporarily unavailable. Please try again later."
self._prefetch_thread = threading.Thread( self._prefetch_thread = threading.Thread(
target=_run, daemon=True, name="mem0-local-prefetch" target=_run, daemon=True, name="mem0-local-prefetch"
+11 -6
View File
@@ -39,17 +39,19 @@ class LocalMem0Client:
endpoint: str, endpoint: str,
json: Optional[Dict] = None, json: Optional[Dict] = None,
params: Optional[Dict] = None, params: Optional[Dict] = None,
timeout: Optional[float] = None,
) -> Dict: ) -> Dict:
"""Make HTTP request with error handling.""" """Make HTTP request with error handling."""
url = f"{self.base_url}{endpoint}" url = f"{self.base_url}{endpoint}"
effective_timeout = timeout if timeout is not None else self.timeout
try: try:
resp = self.session.request( resp = self.session.request(
method, url, json=json, params=params, timeout=self.timeout method, url, json=json, params=params, timeout=effective_timeout
) )
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()
except requests.exceptions.Timeout: except requests.exceptions.Timeout:
logger.error("Mem0 request timed out after %ss", self.timeout) logger.error("Mem0 request timed out after %ss", effective_timeout)
raise raise
except requests.exceptions.ConnectionError as e: except requests.exceptions.ConnectionError as e:
logger.error("Failed to connect to Mem0 server at %s: %s", self.base_url, e) logger.error("Failed to connect to Mem0 server at %s: %s", self.base_url, e)
@@ -66,6 +68,7 @@ class LocalMem0Client:
user_id: Optional[str] = None, user_id: Optional[str] = None,
limit: int = 5, limit: int = 5,
case_insensitive: bool = False, case_insensitive: bool = False,
timeout: Optional[float] = None,
) -> List[Dict]: ) -> List[Dict]:
"""Search memories by semantic similarity. """Search memories by semantic similarity.
@@ -78,18 +81,19 @@ class LocalMem0Client:
user_id: User identifier user_id: User identifier
limit: Max results limit: Max results
case_insensitive: If True, search with both original and lowercase query case_insensitive: If True, search with both original and lowercase query
timeout: Optional per-request timeout override (seconds)
""" """
if not case_insensitive: if not case_insensitive:
payload = {"query": query, "limit": limit} payload = {"query": query, "limit": limit}
if user_id: if user_id:
payload["user_id"] = user_id payload["user_id"] = user_id
result = self._request("POST", "/search", json=payload) result = self._request("POST", "/search", json=payload, timeout=timeout)
return result.get("results", []) return result.get("results", [])
# Case-insensitive mode: search with both original and lowercase # Case-insensitive mode: search with both original and lowercase
# Fetch 2x limit to ensure we get top N after merging # Fetch 2x limit to ensure we get top N after merging
results_original = self._search_with_query(query, user_id, limit * 2) results_original = self._search_with_query(query, user_id, limit * 2, timeout)
results_lower = self._search_with_query(query.lower(), user_id, limit * 2) results_lower = self._search_with_query(query.lower(), user_id, limit * 2, timeout)
# Merge and deduplicate, keeping highest score # Merge and deduplicate, keeping highest score
merged = {} merged = {}
@@ -109,12 +113,13 @@ class LocalMem0Client:
query: str, query: str,
user_id: Optional[str] = None, user_id: Optional[str] = None,
limit: int = 5, limit: int = 5,
timeout: Optional[float] = None,
) -> List[Dict]: ) -> List[Dict]:
"""Internal search helper for case-insensitive mode.""" """Internal search helper for case-insensitive mode."""
payload = {"query": query, "limit": limit} payload = {"query": query, "limit": limit}
if user_id: if user_id:
payload["user_id"] = user_id payload["user_id"] = user_id
result = self._request("POST", "/search", json=payload) result = self._request("POST", "/search", json=payload, timeout=timeout)
return result.get("results", []) return result.get("results", [])
def get_all(self, user_id: Optional[str] = None) -> List[Dict]: def get_all(self, user_id: Optional[str] = None) -> List[Dict]: