Compare commits

...
4 Commits
Author SHA1 Message Date
Nicolò Boschi e48be911a2 fix: include correct __version__ in python packages 2026-01-28 16:16:28 +01:00
Nicolò Boschi 2118d0a7cd Release v0.4.0
- Update version to 0.4.0 in all components
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-litellm, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
2026-01-28 15:04:43 +01:00
Nicolò Boschi e5fc6eedb6 fix(embed): daemon process XPC connection crash on macos (#215)
* fix(embed): daemon process XPC connection crash on macos

* other fix
2026-01-28 14:52:31 +01:00
Nicolò Boschi bb0e0316a7 fix: graph endpoint not showing links for observations (#214) 2026-01-28 14:51:25 +01:00
23 changed files with 757 additions and 40 deletions
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.3.0
appVersion: "0.3.0"
version: 0.4.0
appVersion: "0.4.0"
keywords:
- ai
- memory
+1 -1
View File
@@ -46,4 +46,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.1.0"
__version__ = "0.4.0"
@@ -137,15 +137,26 @@ class LocalSTCrossEncoder(CrossEncoderModel):
# which can cause issues when accelerate is installed but no GPU is available.
# Note: We do NOT use device_map because CrossEncoder internally calls .to(device)
# after loading, which conflicts with accelerate's device_map handling.
import os
import torch
# Check for GPU (CUDA) or Apple Silicon (MPS)
has_gpu = torch.cuda.is_available() or (hasattr(torch.backends, "mps") and torch.backends.mps.is_available())
# Force CPU mode if HINDSIGHT_FORCE_CPU is set (used in daemon mode to avoid MPS/XPC issues)
force_cpu = os.getenv("HINDSIGHT_FORCE_CPU", "0") == "1"
if has_gpu:
device = None # Let sentence-transformers auto-detect GPU/MPS
else:
if force_cpu:
device = "cpu"
logger.info("Reranker: forcing CPU mode (HINDSIGHT_FORCE_CPU=1)")
else:
# Check for GPU (CUDA) or Apple Silicon (MPS)
has_gpu = torch.cuda.is_available() or (
hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
)
if has_gpu:
device = None # Let sentence-transformers auto-detect GPU/MPS
else:
device = "cpu"
self._model = CrossEncoder(
self.model_name,
@@ -163,11 +174,110 @@ class LocalSTCrossEncoder(CrossEncoderModel):
else:
logger.info("Reranker: local provider initialized (using existing executor)")
def _is_xpc_error(self, error: Exception) -> bool:
"""
Check if an error is an XPC connection error (macOS daemon issue).
On macOS, long-running daemons can lose XPC connections to system services
when the process is idle for extended periods.
"""
error_str = str(error).lower()
return "xpc_error_connection_invalid" in error_str or "xpc error" in error_str
def _reinitialize_model_sync(self) -> None:
"""
Clear and reinitialize the cross-encoder model synchronously.
This is used to recover from XPC errors on macOS where the
PyTorch/MPS backend loses its connection to system services.
"""
logger.warning(f"Reinitializing reranker model {self.model_name} due to backend error")
# Clear existing model
self._model = None
# Force garbage collection to free resources
import gc
import torch
gc.collect()
# If using CUDA/MPS, clear the cache
if torch.cuda.is_available():
torch.cuda.empty_cache()
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
try:
torch.mps.empty_cache()
except AttributeError:
pass # Method might not exist in all PyTorch versions
# Reinitialize the model
try:
from sentence_transformers import CrossEncoder
except ImportError:
raise ImportError(
"sentence-transformers is required for LocalSTCrossEncoder. "
"Install it with: pip install sentence-transformers"
)
# Determine device based on hardware availability
import os
force_cpu = os.getenv("HINDSIGHT_FORCE_CPU", "0") == "1"
if force_cpu:
device = "cpu"
else:
has_gpu = torch.cuda.is_available() or (
hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
)
if has_gpu:
device = None # Let sentence-transformers auto-detect GPU/MPS
else:
device = "cpu"
self._model = CrossEncoder(
self.model_name,
device=device,
model_kwargs={"low_cpu_mem_usage": False},
)
logger.info("Reranker: local provider reinitialized successfully")
def _predict_with_recovery(self, pairs: list[tuple[str, str]]) -> list[float]:
"""
Predict with automatic recovery from XPC errors.
This runs synchronously in the thread pool.
"""
max_retries = 1
for attempt in range(max_retries + 1):
try:
scores = self._model.predict(pairs, show_progress_bar=False)
return scores.tolist() if hasattr(scores, "tolist") else list(scores)
except Exception as e:
# Check if this is an XPC error (macOS daemon issue)
if self._is_xpc_error(e) and attempt < max_retries:
logger.warning(f"XPC error detected in reranker (attempt {attempt + 1}): {e}")
try:
self._reinitialize_model_sync()
logger.info("Reranker reinitialized successfully, retrying prediction")
continue
except Exception as reinit_error:
logger.error(f"Failed to reinitialize reranker: {reinit_error}")
raise Exception(f"Failed to recover from XPC error: {str(e)}")
else:
# Not an XPC error or out of retries
raise
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
"""
Score query-document pairs for relevance.
Uses a dedicated thread pool with limited workers to prevent CPU thrashing.
Automatically recovers from XPC errors on macOS by reinitializing the model.
Args:
pairs: List of (query, document) tuples to score
@@ -180,11 +290,11 @@ class LocalSTCrossEncoder(CrossEncoderModel):
# Use dedicated executor - limited workers naturally limits concurrency
loop = asyncio.get_event_loop()
scores = await loop.run_in_executor(
return await loop.run_in_executor(
LocalSTCrossEncoder._executor,
lambda: self._model.predict(pairs, show_progress_bar=False),
self._predict_with_recovery,
pairs,
)
return scores.tolist() if hasattr(scores, "tolist") else list(scores)
class RemoteTEICrossEncoder(CrossEncoderModel):
@@ -132,15 +132,26 @@ class LocalSTEmbeddings(Embeddings):
# Determine device based on hardware availability.
# We always set low_cpu_mem_usage=False to prevent lazy loading (meta tensors)
# which can cause issues when accelerate is installed but no GPU is available.
import os
import torch
# Check for GPU (CUDA) or Apple Silicon (MPS)
has_gpu = torch.cuda.is_available() or (hasattr(torch.backends, "mps") and torch.backends.mps.is_available())
# Force CPU mode if HINDSIGHT_FORCE_CPU is set (used in daemon mode to avoid MPS/XPC issues)
force_cpu = os.getenv("HINDSIGHT_FORCE_CPU", "0") == "1"
if has_gpu:
device = None # Let sentence-transformers auto-detect GPU/MPS
else:
if force_cpu:
device = "cpu"
logger.info("Embeddings: forcing CPU mode (HINDSIGHT_FORCE_CPU=1)")
else:
# Check for GPU (CUDA) or Apple Silicon (MPS)
has_gpu = torch.cuda.is_available() or (
hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
)
if has_gpu:
device = None # Let sentence-transformers auto-detect GPU/MPS
else:
device = "cpu"
self._model = SentenceTransformer(
self.model_name,
@@ -151,10 +162,84 @@ class LocalSTEmbeddings(Embeddings):
self._dimension = self._model.get_sentence_embedding_dimension()
logger.info(f"Embeddings: local provider initialized (dim: {self._dimension})")
def _is_xpc_error(self, error: Exception) -> bool:
"""
Check if an error is an XPC connection error (macOS daemon issue).
On macOS, long-running daemons can lose XPC connections to system services
when the process is idle for extended periods.
"""
error_str = str(error).lower()
return "xpc_error_connection_invalid" in error_str or "xpc error" in error_str
def _reinitialize_model_sync(self) -> None:
"""
Clear and reinitialize the embedding model synchronously.
This is used to recover from XPC errors on macOS where the
PyTorch/MPS backend loses its connection to system services.
"""
logger.warning(f"Reinitializing embedding model {self.model_name} due to backend error")
# Clear existing model
self._model = None
# Force garbage collection to free resources
import gc
import torch
gc.collect()
# If using CUDA/MPS, clear the cache
if torch.cuda.is_available():
torch.cuda.empty_cache()
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
try:
torch.mps.empty_cache()
except AttributeError:
pass # Method might not exist in all PyTorch versions
# Reinitialize the model (inline version of initialize() but synchronous)
try:
from sentence_transformers import SentenceTransformer
except ImportError:
raise ImportError(
"sentence-transformers is required for LocalSTEmbeddings. "
"Install it with: pip install sentence-transformers"
)
# Determine device based on hardware availability
import os
force_cpu = os.getenv("HINDSIGHT_FORCE_CPU", "0") == "1"
if force_cpu:
device = "cpu"
else:
has_gpu = torch.cuda.is_available() or (
hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
)
if has_gpu:
device = None # Let sentence-transformers auto-detect GPU/MPS
else:
device = "cpu"
self._model = SentenceTransformer(
self.model_name,
device=device,
model_kwargs={"low_cpu_mem_usage": False},
)
logger.info("Embeddings: local provider reinitialized successfully")
def encode(self, texts: list[str]) -> list[list[float]]:
"""
Generate embeddings for a list of texts.
Automatically recovers from XPC errors on macOS by reinitializing the model.
Args:
texts: List of text strings to encode
@@ -163,8 +248,27 @@ class LocalSTEmbeddings(Embeddings):
"""
if self._model is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
embeddings = self._model.encode(texts, convert_to_numpy=True, show_progress_bar=False)
return [emb.tolist() for emb in embeddings]
# Try encoding with automatic recovery from XPC errors
max_retries = 1
for attempt in range(max_retries + 1):
try:
embeddings = self._model.encode(texts, convert_to_numpy=True, show_progress_bar=False)
return [emb.tolist() for emb in embeddings]
except Exception as e:
# Check if this is an XPC error (macOS daemon issue)
if self._is_xpc_error(e) and attempt < max_retries:
logger.warning(f"XPC error detected in embedding generation (attempt {attempt + 1}): {e}")
try:
self._reinitialize_model_sync()
logger.info("Model reinitialized successfully, retrying embedding generation")
continue
except Exception as reinit_error:
logger.error(f"Failed to reinitialize model: {reinit_error}")
raise Exception(f"Failed to recover from XPC error: {str(e)}")
else:
# Not an XPC error or out of retries
raise
class RemoteTEIEmbeddings(Embeddings):
@@ -2764,7 +2764,7 @@ class MemoryEngine(MemoryEngineInterface):
param_count += 1
units = await conn.fetch(
f"""
SELECT id, text, event_date, context, occurred_start, occurred_end, mentioned_at, document_id, chunk_id, fact_type, tags, created_at, proof_count
SELECT id, text, event_date, context, occurred_start, occurred_end, mentioned_at, document_id, chunk_id, fact_type, tags, created_at, proof_count, source_memory_ids
FROM {fq_table("memory_units")}
{where_clause}
ORDER BY mentioned_at DESC NULLS LAST, event_date DESC
@@ -2777,7 +2777,18 @@ class MemoryEngine(MemoryEngineInterface):
# Get links, filtering to only include links between units of the selected agent
# Use DISTINCT ON with LEAST/GREATEST to deduplicate bidirectional links
unit_ids = [row["id"] for row in units]
if unit_ids:
unit_id_set = set(unit_ids)
# Collect source memory IDs from observations
source_memory_ids = []
for unit in units:
if unit["source_memory_ids"]:
source_memory_ids.extend(unit["source_memory_ids"])
source_memory_ids = list(set(source_memory_ids)) # Deduplicate
# Fetch links involving both visible units AND source memories
all_relevant_ids = unit_ids + source_memory_ids
if all_relevant_ids:
links = await conn.fetch(
f"""
SELECT DISTINCT ON (LEAST(ml.from_unit_id, ml.to_unit_id), GREATEST(ml.from_unit_id, ml.to_unit_id), ml.link_type, COALESCE(ml.entity_id, '00000000-0000-0000-0000-000000000000'::uuid))
@@ -2788,14 +2799,69 @@ class MemoryEngine(MemoryEngineInterface):
e.canonical_name as entity_name
FROM {fq_table("memory_links")} ml
LEFT JOIN {fq_table("entities")} e ON ml.entity_id = e.id
WHERE ml.from_unit_id = ANY($1::uuid[]) AND ml.to_unit_id = ANY($1::uuid[])
WHERE ml.from_unit_id = ANY($1::uuid[]) OR ml.to_unit_id = ANY($1::uuid[])
ORDER BY LEAST(ml.from_unit_id, ml.to_unit_id), GREATEST(ml.from_unit_id, ml.to_unit_id), ml.link_type, COALESCE(ml.entity_id, '00000000-0000-0000-0000-000000000000'::uuid), ml.weight DESC
""",
unit_ids,
all_relevant_ids,
)
else:
links = []
# Copy links from source memories to observations
# Observations inherit links from their source memories via source_memory_ids
# Build a map from source_id to observation_ids
source_to_observations = {}
for unit in units:
if unit["source_memory_ids"]:
for source_id in unit["source_memory_ids"]:
if source_id not in source_to_observations:
source_to_observations[source_id] = []
source_to_observations[source_id].append(unit["id"])
copied_links = []
for link in links:
from_id = link["from_unit_id"]
to_id = link["to_unit_id"]
# Get observations that should inherit this link
from_observations = source_to_observations.get(from_id, [])
to_observations = source_to_observations.get(to_id, [])
# If from_id is a source memory, copy links to its observations
if from_observations:
for obs_id in from_observations:
# Only include if the target is visible
if to_id in unit_id_set or to_observations:
target = to_observations[0] if to_observations and to_id not in unit_id_set else to_id
if target in unit_id_set:
copied_links.append(
{
"from_unit_id": obs_id,
"to_unit_id": target,
"link_type": link["link_type"],
"weight": link["weight"],
"entity_name": link["entity_name"],
}
)
# If to_id is a source memory, copy links to its observations
if to_observations and from_id in unit_id_set:
for obs_id in to_observations:
copied_links.append(
{
"from_unit_id": from_id,
"to_unit_id": obs_id,
"link_type": link["link_type"],
"weight": link["weight"],
"entity_name": link["entity_name"],
}
)
# Keep only direct links between visible nodes
direct_links = [
link for link in links if link["from_unit_id"] in unit_id_set and link["to_unit_id"] in unit_id_set
]
# Get entity information
unit_entities = await conn.fetch(f"""
SELECT ue.unit_id, e.canonical_name
@@ -2813,6 +2879,18 @@ class MemoryEngine(MemoryEngineInterface):
entity_map[unit_id] = []
entity_map[unit_id].append(entity_name)
# For observations, inherit entities from source memories
for unit in units:
if unit["source_memory_ids"] and unit["id"] not in entity_map:
# Collect entities from all source memories
source_entities = []
for source_id in unit["source_memory_ids"]:
if source_id in entity_map:
source_entities.extend(entity_map[source_id])
if source_entities:
# Deduplicate while preserving order
entity_map[unit["id"]] = list(dict.fromkeys(source_entities))
# Build nodes
nodes = []
for row in units:
@@ -2846,14 +2924,15 @@ class MemoryEngine(MemoryEngineInterface):
}
)
# Build edges
# Build edges (combine direct links and copied links from sources)
edges = []
for row in links:
all_links = direct_links + copied_links
for row in all_links:
from_id = str(row["from_unit_id"])
to_id = str(row["to_unit_id"])
link_type = row["link_type"]
weight = row["weight"]
entity_name = row["entity_name"]
entity_name = row.get("entity_name")
# Color by link type
if link_type == "temporal":
@@ -58,6 +58,7 @@ def _normalize_tool_name(name: str) -> str:
- 'functions.done' (OpenAI-style prefix)
- 'call=functions.done' (some models)
- 'call=done' (some models)
- 'done<|channel|>commentary' (malformed special tokens appended)
Returns the normalized tool name (e.g., 'done', 'recall', etc.)
"""
@@ -69,6 +70,11 @@ def _normalize_tool_name(name: str) -> str:
if name.startswith("functions."):
name = name[len("functions.") :]
# Handle malformed special tokens appended to tool name
# e.g., 'done<|channel|>commentary' -> 'done'
if "<|" in name:
name = name.split("<|")[0]
return name
@@ -155,7 +155,6 @@ class LinkExpansionRetriever(GraphRetriever):
all_seeds.extend(temporal_seeds)
if not all_seeds:
logger.info("[LinkExpansion] No seeds found, returning empty results")
return [], timings
seed_ids = list({s.id for s in all_seeds})
+6
View File
@@ -140,6 +140,12 @@ def main():
args.port = DEFAULT_DAEMON_PORT
args.host = "127.0.0.1" # Only bind to localhost for security
# Force CPU mode for daemon to avoid macOS MPS/XPC issues
# MPS (Metal Performance Shaders) has unstable XPC connections in background processes
# that can cause assertion failures and process crashes at the C++ level
# (which Python exception handlers cannot catch)
os.environ["HINDSIGHT_FORCE_CPU"] = "1"
# Check if another daemon is already running
daemon_lock = DaemonLock()
if not daemon_lock.acquire():
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api"
version = "0.3.0"
version = "0.4.0"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
+90
View File
@@ -1897,3 +1897,93 @@ class TestMentalModelRefreshAfterConsolidation:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_graph_endpoint_observations_inherit_links_and_entities(
self, memory: MemoryEngine, request_context
):
"""Test that graph endpoint shows links and entities for observations filtered by type.
When filtering graph by type=observation:
- Observations should inherit links from their source memories
- Observations should show entities inherited from source memories
- Even when source memories are not visible, their links should be copied to observations
"""
bank_id = f"test-graph-obs-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Retain content that will create world facts with shared entities
# This should create facts that are linked by shared entities
await memory.retain_async(
bank_id=bank_id,
content="Alice works at Google as a software engineer.",
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="Bob also works at Google in the sales department.",
request_context=request_context,
)
# Wait for consolidation to create observations
import asyncio
await asyncio.sleep(2)
# Get graph data filtered by observation type only
graph_data = await memory.get_graph_data(
bank_id=bank_id,
fact_type="observation",
limit=1000,
request_context=request_context,
)
# Should have observations
assert graph_data["total_units"] > 0, "Should have observations"
assert len(graph_data["nodes"]) > 0, "Should have observation nodes"
# Verify all nodes are observations
for row in graph_data["table_rows"]:
assert row["fact_type"] == "observation", f"All nodes should be observations, got {row['fact_type']}"
# Should have edges (inherited from source memories)
# Even though we're only showing observations, they should inherit links from their sources
assert len(graph_data["edges"]) > 0, (
"Observations should have edges inherited from source memories. "
f"Found {len(graph_data['edges'])} edges"
)
# Should have entities (inherited from source memories)
observations_with_entities = [
row for row in graph_data["table_rows"] if row["entities"] and row["entities"] != "None"
]
assert len(observations_with_entities) > 0, (
"Observations should inherit entities from source memories. "
f"Found {len(observations_with_entities)} observations with entities"
)
# Verify entities contain expected values
all_entities = " ".join([row["entities"] for row in graph_data["table_rows"]])
assert "Alice" in all_entities or "Bob" in all_entities or "Google" in all_entities, (
f"Expected to find Alice, Bob, or Google in entities, got: {all_entities}"
)
# Verify edge types are valid
valid_link_types = {"semantic", "temporal", "entity"}
for edge in graph_data["edges"]:
link_type = edge["data"]["linkType"]
assert link_type in valid_link_types, f"Invalid link type: {link_type}"
# Verify all edges connect visible observation nodes
visible_node_ids = {row["id"] for row in graph_data["table_rows"]}
for edge in graph_data["edges"]:
source_id = edge["data"]["source"]
target_id = edge["data"]["target"]
assert source_id in visible_node_ids, f"Edge source {source_id[:8]} not in visible nodes"
assert target_id in visible_node_ids, f"Edge target {target_id[:8]} not in visible nodes"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@@ -0,0 +1,148 @@
"""
Tests for XPC error recovery in LocalSTCrossEncoder.
This tests the automatic reinitialization of the cross-encoder model when
XPC connection errors occur on macOS (common in long-running daemon processes).
"""
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from hindsight_api.engine.cross_encoder import LocalSTCrossEncoder
class TestCrossEncoderXPCErrorRecovery:
"""Tests for XPC error detection and recovery in LocalSTCrossEncoder."""
@pytest.fixture
def cross_encoder(self):
"""Create a LocalSTCrossEncoder instance."""
return LocalSTCrossEncoder(model_name="cross-encoder/ms-marco-TinyBERT-L-2-v2")
def test_is_xpc_error_detection(self, cross_encoder):
"""Test that XPC errors are correctly detected."""
# Test various XPC error message formats
xpc_error = Exception("Compiler encountered XPC_ERROR_CONNECTION_INVALID (is the OS shutting down?)")
assert cross_encoder._is_xpc_error(xpc_error)
xpc_error2 = Exception("XPC error occurred")
assert cross_encoder._is_xpc_error(xpc_error2)
# Test that non-XPC errors are not detected
normal_error = Exception("Some other error")
assert not cross_encoder._is_xpc_error(normal_error)
@pytest.mark.asyncio
async def test_predict_with_xpc_recovery(self, cross_encoder):
"""Test that predict() recovers from XPC errors by reinitializing."""
# Initialize the cross-encoder
await cross_encoder.initialize()
# Track calls to reinitialize
reinit_called = False
original_reinit = cross_encoder._reinitialize_model_sync
def track_reinit():
nonlocal reinit_called
reinit_called = True
original_reinit()
# Track predict attempts
predict_attempts = []
original_predict = cross_encoder._model.predict
def mock_predict(*args, **kwargs):
predict_attempts.append(1)
# Only fail on first attempt
if len(predict_attempts) == 1:
raise RuntimeError("Compiler encountered XPC_ERROR_CONNECTION_INVALID (is the OS shutting down?)")
else:
# After reinit: succeed
return original_predict(*args, **kwargs)
# Mock the initial predict to fail, reinit happens, then new model succeeds
with patch.object(cross_encoder, "_reinitialize_model_sync", side_effect=track_reinit):
with patch.object(cross_encoder._model, "predict", side_effect=mock_predict):
# This should trigger XPC error on first attempt, then recover and succeed
result = await cross_encoder.predict([("query", "document")])
# Verify we got a result
assert result is not None
assert len(result) == 1
assert isinstance(result[0], float)
assert reinit_called # Should have reinitialized
assert len(predict_attempts) >= 1 # At least one attempt was made
@pytest.mark.asyncio
async def test_predict_fails_on_non_xpc_error(self, cross_encoder):
"""Test that predict() does not retry for non-XPC errors."""
# Initialize the cross-encoder
await cross_encoder.initialize()
# Create a mock that raises a non-XPC error
def mock_predict(*args, **kwargs):
raise RuntimeError("Some other error")
# Patch the model's predict method
with patch.object(cross_encoder._model, "predict", side_effect=mock_predict):
# This should fail without retry
with pytest.raises(RuntimeError) as exc_info:
await cross_encoder.predict([("query", "document")])
assert "Some other error" in str(exc_info.value)
@pytest.mark.asyncio
async def test_reinitialize_clears_model(self, cross_encoder):
"""Test that _reinitialize_model_sync properly clears and reinits the model."""
# Initialize the cross-encoder
await cross_encoder.initialize()
original_model = cross_encoder._model
assert original_model is not None
# Reinitialize
cross_encoder._reinitialize_model_sync()
# Model should be reinitialized (new instance)
assert cross_encoder._model is not None
assert cross_encoder._model is not original_model
# Should still work
result = await cross_encoder.predict([("test query", "test document")])
assert len(result) == 1
assert isinstance(result[0], float)
@pytest.mark.asyncio
async def test_xpc_recovery_exhausts_retries(self, cross_encoder):
"""Test that XPC recovery gives up after max retries."""
# Initialize the cross-encoder
await cross_encoder.initialize()
# Track reinit calls
reinit_count = 0
original_reinit = cross_encoder._reinitialize_model_sync
def track_and_fail_reinit():
nonlocal reinit_count
reinit_count += 1
# Call original reinit, but the new model will also be mocked to fail
original_reinit()
# After reinit, patch the new model too
cross_encoder._model.predict = MagicMock(
side_effect=RuntimeError("Compiler encountered XPC_ERROR_CONNECTION_INVALID")
)
# Mock that always raises XPC error
cross_encoder._model.predict = MagicMock(
side_effect=RuntimeError("Compiler encountered XPC_ERROR_CONNECTION_INVALID")
)
with patch.object(cross_encoder, "_reinitialize_model_sync", side_effect=track_and_fail_reinit):
# Should try once, reinitialize, try again, and fail
with pytest.raises(Exception) as exc_info:
await cross_encoder.predict([("query", "document")])
assert "XPC_ERROR_CONNECTION_INVALID" in str(exc_info.value) or "Failed to recover" in str(exc_info.value)
assert reinit_count == 1 # Should have tried to reinitialize once
@@ -0,0 +1,148 @@
"""
Tests for XPC error recovery in LocalSTEmbeddings.
This tests the automatic reinitialization of the embedding model when
XPC connection errors occur on macOS (common in long-running daemon processes).
"""
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from hindsight_api.engine.embeddings import LocalSTEmbeddings
class TestXPCErrorRecovery:
"""Tests for XPC error detection and recovery in LocalSTEmbeddings."""
@pytest.fixture
def embeddings(self):
"""Create a LocalSTEmbeddings instance."""
return LocalSTEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2")
def test_is_xpc_error_detection(self, embeddings):
"""Test that XPC errors are correctly detected."""
# Test various XPC error message formats
xpc_error = Exception("Compiler encountered XPC_ERROR_CONNECTION_INVALID (is the OS shutting down?)")
assert embeddings._is_xpc_error(xpc_error)
xpc_error2 = Exception("XPC error occurred")
assert embeddings._is_xpc_error(xpc_error2)
# Test that non-XPC errors are not detected
normal_error = Exception("Some other error")
assert not embeddings._is_xpc_error(normal_error)
@pytest.mark.asyncio
async def test_encode_with_xpc_recovery(self, embeddings):
"""Test that encode() recovers from XPC errors by reinitializing."""
# Initialize the embeddings
await embeddings.initialize()
# Track calls to reinitialize
reinit_called = False
original_reinit = embeddings._reinitialize_model_sync
def track_reinit():
nonlocal reinit_called
reinit_called = True
original_reinit()
# Track encode attempts
encode_attempts = []
original_encode = embeddings._model.encode
def mock_encode(*args, **kwargs):
encode_attempts.append(1)
# Only fail on first attempt
if len(encode_attempts) == 1:
raise RuntimeError("Compiler encountered XPC_ERROR_CONNECTION_INVALID (is the OS shutting down?)")
else:
# After reinit: succeed
return original_encode(*args, **kwargs)
# Mock the initial encode to fail, reinit happens, then new model succeeds
with patch.object(embeddings, "_reinitialize_model_sync", side_effect=track_reinit):
with patch.object(embeddings._model, "encode", side_effect=mock_encode):
# This should trigger XPC error on first attempt, then recover and succeed
result = embeddings.encode(["test text"])
# Verify we got a result
assert result is not None
assert len(result) == 1
assert len(result[0]) > 0 # Should have embedding vector
assert reinit_called # Should have reinitialized
assert len(encode_attempts) >= 1 # At least one attempt was made
@pytest.mark.asyncio
async def test_encode_fails_on_non_xpc_error(self, embeddings):
"""Test that encode() does not retry for non-XPC errors."""
# Initialize the embeddings
await embeddings.initialize()
# Create a mock that raises a non-XPC error
def mock_encode(*args, **kwargs):
raise RuntimeError("Some other error")
# Patch the model's encode method
with patch.object(embeddings._model, "encode", side_effect=mock_encode):
# This should fail without retry
with pytest.raises(RuntimeError) as exc_info:
embeddings.encode(["test text"])
assert "Some other error" in str(exc_info.value)
@pytest.mark.asyncio
async def test_reinitialize_clears_model(self, embeddings):
"""Test that _reinitialize_model_sync properly clears and reinits the model."""
# Initialize the embeddings
await embeddings.initialize()
original_model = embeddings._model
assert original_model is not None
# Reinitialize
embeddings._reinitialize_model_sync()
# Model should be reinitialized (new instance)
assert embeddings._model is not None
assert embeddings._model is not original_model
# Should still work
result = embeddings.encode(["test"])
assert len(result) == 1
assert len(result[0]) > 0
@pytest.mark.asyncio
async def test_xpc_recovery_exhausts_retries(self, embeddings):
"""Test that XPC recovery gives up after max retries."""
# Initialize the embeddings
await embeddings.initialize()
# Track reinit calls
reinit_count = 0
original_reinit = embeddings._reinitialize_model_sync
def track_and_fail_reinit():
nonlocal reinit_count
reinit_count += 1
# Call original reinit, but the new model will also be mocked to fail
original_reinit()
# After reinit, patch the new model too
embeddings._model.encode = MagicMock(
side_effect=RuntimeError("Compiler encountered XPC_ERROR_CONNECTION_INVALID")
)
# Mock that always raises XPC error
embeddings._model.encode = MagicMock(
side_effect=RuntimeError("Compiler encountered XPC_ERROR_CONNECTION_INVALID")
)
with patch.object(embeddings, "_reinitialize_model_sync", side_effect=track_and_fail_reinit):
# Should try once, reinitialize, try again, and fail
with pytest.raises(RuntimeError) as exc_info:
embeddings.encode(["test"])
assert "XPC_ERROR_CONNECTION_INVALID" in str(exc_info.value)
assert reinit_count == 1 # Should have tried to reinitialize once
+11
View File
@@ -163,6 +163,12 @@ class TestToolNameNormalization:
assert _normalize_tool_name("call=functions.recall") == "recall"
assert _normalize_tool_name("call=functions.search_observations") == "search_observations"
def test_normalize_special_token_suffix(self):
"""Tool names with malformed special tokens should be normalized."""
assert _normalize_tool_name("done<|channel|>commentary") == "done"
assert _normalize_tool_name("recall<|endoftext|>") == "recall"
assert _normalize_tool_name("search_observations<|im_end|>extra") == "search_observations"
def test_is_done_tool(self):
"""Test _is_done_tool helper."""
# Standard
@@ -174,9 +180,14 @@ class TestToolNameNormalization:
assert _is_done_tool("call=done") is True
assert _is_done_tool("call=functions.done") is True
# With malformed special tokens
assert _is_done_tool("done<|channel|>commentary") is True
assert _is_done_tool("done<|endoftext|>") is True
# Not done
assert _is_done_tool("functions.recall") is False
assert _is_done_tool("call=functions.recall") is False
assert _is_done_tool("recall<|channel|>done") is False
class TestReflectAgentMocked:
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hindsight-cli"
version = "0.3.0"
version = "0.4.0"
edition = "2021"
authors = ["Hindsight Team"]
description = "A beautiful CLI for Hindsight - semantic memory system"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "hindsight-client"
version = "0.3.0"
version = "0.4.0"
description = "Python client for Hindsight - Semantic memory system with personality-driven thinking"
authors = [
{name = "Hindsight Team"}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@vectorize-io/hindsight-client",
"version": "0.3.0",
"version": "0.4.0",
"description": "TypeScript client for Hindsight - Semantic memory system with personality-driven thinking",
"main": "./dist/src/index.js",
"types": "./dist/src/index.d.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@vectorize-io/hindsight-control-plane",
"version": "0.3.0",
"version": "0.4.0",
"description": "Control plane for Hindsight - Semantic memory system",
"bin": {
"hindsight-control-plane": "./bin/cli.js"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-dev"
version = "0.3.0"
version = "0.4.0"
description = "Development utilities for Hindsight"
requires-python = ">=3.11"
dependencies = [
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-embed"
version = "0.3.0"
version = "0.4.0"
description = "Hindsight embedded CLI - local memory operations without a server"
readme = "README.md"
requires-python = ">=3.11"
@@ -1,6 +1,6 @@
[project]
name = "hindsight-litellm"
version = "0.3.0"
version = "0.4.0"
description = "Universal LLM memory integration via LiteLLM - works with 100+ providers"
readme = "README.md"
requires-python = ">=3.10"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-all"
version = "0.3.0"
version = "0.4.0"
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
readme = "README.md"
requires-python = ">=3.11"
+16
View File
@@ -77,6 +77,22 @@ for package in "${PYTHON_PACKAGES[@]}"; do
fi
done
# Update __version__ in Python __init__.py files
PYTHON_INIT_FILES=(
"hindsight-api/hindsight_api/__init__.py"
"hindsight-embed/hindsight_embed/__init__.py"
"hindsight-clients/python/hindsight_client_api/__init__.py"
)
for init_file in "${PYTHON_INIT_FILES[@]}"; do
if [ -f "$init_file" ]; then
print_info "Updating __version__ in $init_file"
sed -i.bak "s/^__version__ = \".*\"/__version__ = \"$VERSION\"/" "$init_file"
rm "${init_file}.bak"
else
print_warn "File $init_file not found, skipping"
fi
done
# Update Rust CLI
CARGO_FILE="hindsight-cli/Cargo.toml"
if [ -f "$CARGO_FILE" ]; then
Generated
+5 -5
View File
@@ -1295,7 +1295,7 @@ wheels = [
[[package]]
name = "hindsight-all"
version = "0.3.0"
version = "0.4.0"
source = { editable = "hindsight" }
dependencies = [
{ name = "hindsight-api" },
@@ -1319,7 +1319,7 @@ provides-extras = ["test"]
[[package]]
name = "hindsight-api"
version = "0.3.0"
version = "0.4.0"
source = { editable = "hindsight-api" }
dependencies = [
{ name = "aiohttp" },
@@ -1447,7 +1447,7 @@ dev = [
[[package]]
name = "hindsight-client"
version = "0.3.0"
version = "0.4.0"
source = { editable = "hindsight-clients/python" }
dependencies = [
{ name = "aiohttp" },
@@ -1481,7 +1481,7 @@ provides-extras = ["test"]
[[package]]
name = "hindsight-dev"
version = "0.3.0"
version = "0.4.0"
source = { editable = "hindsight-dev" }
dependencies = [
{ name = "hindsight-api" },
@@ -1527,7 +1527,7 @@ dev = [
[[package]]
name = "hindsight-embed"
version = "0.3.0"
version = "0.4.0"
source = { editable = "hindsight-embed" }
dependencies = [
{ name = "httpx" },