Compare commits

...
4 Commits
Author SHA1 Message Date
Nicolò Boschi 00d46c3a73 other fix 2026-01-28 14:43:35 +01:00
Nicolò Boschi 320712f998 fix(embed): daemon process XPC connection crash on macos 2026-01-28 14:34:42 +01:00
Nicolò Boschi 3172e99cab feat: add custom extraction prompt (#213)
* feat: add custom extraction prompt

* feat: add custom extraction prompt

* test
2026-01-28 13:54:52 +01:00
Nicolò BoschiandClaude Sonnet 4.5 1c9a7a0d5e chore: cleanup benchmarks runner with old flags (#212)
* chore: cleanup benchmarks runner with old flags

* fix tests

* fix: observations rely on source_memory_ids, no link copying

Observations no longer copy any memory_links from their source facts.
Instead, retrieval uses source_memory_ids to traverse:
- Entity connections: observation → source_memory_ids → unit_entities
- Semantic similarity: observations have their own embeddings
- Temporal proximity: observations have their own temporal fields

This avoids data duplication and fixes bidirectionality issues with
entity links being copied to observations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>

* test: update consolidation test for source_memory_ids behavior

Updated test_consolidation_creates_memory_links to test_consolidation_uses_source_memory_ids
to reflect the new behavior where observations use source_memory_ids instead of memory_links
for traversal.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>

---------

Co-authored-by: Claude Sonnet 4.5 <[email protected]>
2026-01-28 13:22:48 +01:00
20 changed files with 1054 additions and 338 deletions
+7 -3
View File
@@ -92,6 +92,7 @@ ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"
ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE"
ENV_RETAIN_EXTRACT_CAUSAL_LINKS = "HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS"
ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE"
ENV_RETAIN_CUSTOM_INSTRUCTIONS = "HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS"
ENV_RETAIN_OBSERVATIONS_ASYNC = "HINDSIGHT_API_RETAIN_OBSERVATIONS_ASYNC"
# Observations settings (consolidated knowledge from facts)
@@ -168,12 +169,13 @@ DEFAULT_MENTAL_MODEL_REFRESH_CONCURRENCY = 8 # Max concurrent mental model refr
DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 64000 # Max tokens for fact extraction LLM call
DEFAULT_RETAIN_CHUNK_SIZE = 3000 # Max chars per chunk for fact extraction
DEFAULT_RETAIN_EXTRACT_CAUSAL_LINKS = True # Extract causal links between facts
DEFAULT_RETAIN_EXTRACTION_MODE = "concise" # Extraction mode: "concise" or "verbose"
RETAIN_EXTRACTION_MODES = ("concise", "verbose") # Allowed extraction modes
DEFAULT_RETAIN_EXTRACTION_MODE = "concise" # Extraction mode: "concise", "verbose", or "custom"
RETAIN_EXTRACTION_MODES = ("concise", "verbose", "custom") # Allowed extraction modes
DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS = None # Custom extraction guidelines (only used when mode="custom")
DEFAULT_RETAIN_OBSERVATIONS_ASYNC = False # Run observation generation async (after retain completes)
# Observations defaults (consolidated knowledge from facts)
DEFAULT_ENABLE_OBSERVATIONS = False # Observations disabled by default (experimental)
DEFAULT_ENABLE_OBSERVATIONS = True # Observations enabled by default
DEFAULT_CONSOLIDATION_BATCH_SIZE = 50 # Memories to load per batch (internal memory optimization)
# Database migrations
@@ -328,6 +330,7 @@ class HindsightConfig:
retain_chunk_size: int
retain_extract_causal_links: bool
retain_extraction_mode: str
retain_custom_instructions: str | None
retain_observations_async: bool
# Observations settings (consolidated knowledge from facts)
@@ -431,6 +434,7 @@ class HindsightConfig:
retain_extraction_mode=_validate_extraction_mode(
os.getenv(ENV_RETAIN_EXTRACTION_MODE, DEFAULT_RETAIN_EXTRACTION_MODE)
),
retain_custom_instructions=os.getenv(ENV_RETAIN_CUSTOM_INSTRUCTIONS) or DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS,
retain_observations_async=os.getenv(
ENV_RETAIN_OBSERVATIONS_ASYNC, str(DEFAULT_RETAIN_OBSERVATIONS_ASYNC)
).lower()
@@ -254,11 +254,79 @@ async def run_consolidation_job(
if timing_parts:
perf.log(f"[4] Timing breakdown: {', '.join(timing_parts)}")
# Trigger mental model refreshes for models with refresh_after_consolidation=true
mental_models_refreshed = await _trigger_mental_model_refreshes(
memory_engine=memory_engine,
bank_id=bank_id,
request_context=request_context,
perf=perf,
)
stats["mental_models_refreshed"] = mental_models_refreshed
perf.flush()
return {"status": "completed", "bank_id": bank_id, **stats}
async def _trigger_mental_model_refreshes(
memory_engine: "MemoryEngine",
bank_id: str,
request_context: "RequestContext",
perf: ConsolidationPerfLog | None = None,
) -> int:
"""
Trigger refreshes for mental models with refresh_after_consolidation=true.
Args:
memory_engine: MemoryEngine instance
bank_id: Bank identifier
request_context: Request context for authentication
perf: Performance logging
Returns:
Number of mental models scheduled for refresh
"""
pool = memory_engine._pool
# Find mental models with refresh_after_consolidation=true
async with pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT id, name
FROM {fq_table("mental_models")}
WHERE bank_id = $1
AND (trigger->>'refresh_after_consolidation')::boolean = true
""",
bank_id,
)
if not rows:
return 0
if perf:
perf.log(f"[5] Triggering refresh for {len(rows)} mental models with refresh_after_consolidation=true")
# Submit refresh tasks for each mental model
refreshed_count = 0
for row in rows:
mental_model_id = row["id"]
try:
await memory_engine.submit_async_refresh_mental_model(
bank_id=bank_id,
mental_model_id=mental_model_id,
request_context=request_context,
)
refreshed_count += 1
logger.info(
f"[CONSOLIDATION] Triggered refresh for mental model {mental_model_id} "
f"(name: {row['name']}) in bank {bank_id}"
)
except Exception as e:
logger.warning(f"[CONSOLIDATION] Failed to trigger refresh for mental model {mental_model_id}: {e}")
return refreshed_count
async def _process_memory(
conn: "Connection",
memory_engine: "MemoryEngine",
@@ -301,11 +369,11 @@ async def _process_memory(
perf.record_timing("recall", time.time() - t0)
# Single LLM call handles ALL cases (with or without existing observations)
# Note: Tags are NOT passed to LLM - they are handled algorithmically
t0 = time.time()
actions = await _consolidate_with_llm(
memory_engine=memory_engine,
fact_text=fact_text,
fact_tags=fact_tags,
observations=related_observations, # Can be empty list
mission=mission,
)
@@ -328,6 +396,7 @@ async def _process_memory(
memory_id=memory_id,
action=action,
observations=related_observations,
source_fact_tags=fact_tags, # Pass source fact's tags for security
source_occurred_start=memory.get("occurred_start"),
source_occurred_end=memory.get("occurred_end"),
source_mentioned_at=memory.get("mentioned_at"),
@@ -341,6 +410,7 @@ async def _process_memory(
bank_id=bank_id,
memory_id=memory_id,
action=action,
source_fact_tags=fact_tags, # Pass source fact's tags for security
event_date=memory.get("event_date"),
occurred_start=memory.get("occurred_start"),
occurred_end=memory.get("occurred_end"),
@@ -377,6 +447,7 @@ async def _execute_update_action(
memory_id: uuid.UUID,
action: dict[str, Any],
observations: list[dict[str, Any]],
source_fact_tags: list[str] | None = None,
source_occurred_start: datetime | None = None,
source_occurred_end: datetime | None = None,
source_mentioned_at: datetime | None = None,
@@ -390,6 +461,11 @@ async def _execute_update_action(
- occurred_start: uses LEAST to keep the earliest start time
- occurred_end: uses GREATEST to keep the most recent end time
- mentioned_at: uses GREATEST to keep the most recent mention time
SECURITY: Merges source fact's tags into the observation's existing tags.
This ensures all contributors can see the observation they contributed to.
For example, if Lisa's observation (tags=['user_lisa']) is updated with
Mike's fact (tags=['user_mike']), the observation will have both tags.
"""
learning_id = action.get("learning_id")
new_text = action.get("text")
@@ -418,6 +494,17 @@ async def _execute_update_action(
source_ids = list(model.get("source_memory_ids", []))
source_ids.append(memory_id)
# SECURITY: Merge source fact's tags into existing observation tags
# This ensures all contributors can see the observation they contributed to
existing_tags = set(model.get("tags", []) or [])
source_tags = set(source_fact_tags or [])
merged_tags = list(existing_tags | source_tags) # Union of both tag sets
if source_tags and source_tags != existing_tags:
logger.debug(
f"Security: Merging tags for observation {learning_id}: "
f"existing={list(existing_tags)}, source={list(source_tags)}, merged={merged_tags}"
)
# Generate new embedding for updated text
t0 = time.time()
embeddings = await embedding_utils.generate_embeddings_batch(memory_engine.embeddings, [new_text])
@@ -429,6 +516,7 @@ async def _execute_update_action(
# - occurred_start: LEAST keeps the earliest start time across all source facts
# - occurred_end: GREATEST keeps the most recent end time across all source facts
# - mentioned_at: GREATEST keeps the most recent mention time
# - tags: merged from existing + source fact (for visibility)
t0 = time.time()
await conn.execute(
f"""
@@ -438,6 +526,7 @@ async def _execute_update_action(
history = $3,
source_memory_ids = $4,
proof_count = $5,
tags = $10,
updated_at = now(),
occurred_start = LEAST(occurred_start, COALESCE($7, occurred_start)),
occurred_end = GREATEST(occurred_end, COALESCE($8, occurred_end)),
@@ -453,6 +542,7 @@ async def _execute_update_action(
source_occurred_start,
source_occurred_end,
source_mentioned_at,
merged_tags,
)
# Create links from memory to observation
@@ -471,6 +561,7 @@ async def _execute_create_action(
bank_id: str,
memory_id: uuid.UUID,
action: dict[str, Any],
source_fact_tags: list[str] | None = None,
event_date: datetime | None = None,
occurred_start: datetime | None = None,
occurred_end: datetime | None = None,
@@ -480,11 +571,18 @@ async def _execute_create_action(
"""
Execute a create action for a new observation.
Creates a new observation with the specified text and tags.
Creates a new observation with the specified text.
The text comes directly from the classify LLM - no second LLM call needed.
Tags are determined algorithmically (not by LLM):
- Observations always inherit their source fact's tags
- This ensures visibility scope is maintained (security)
"""
text = action.get("text")
tags = action.get("tags", [])
# Tags are determined algorithmically - always use source fact's tags
# This ensures private memories create private observations
tags = source_fact_tags or []
if not text:
return {"action": "skipped", "reason": "missing_text"}
@@ -515,83 +613,22 @@ async def _create_memory_links(
observation_id: uuid.UUID,
) -> None:
"""
Create links between a source memory and its observation.
Placeholder for observation link creation.
This:
1. Creates bidirectional semantic links between memory and observation
2. Copies existing memory_links from the source memory to the observation
Observations do NOT get any memory_links copied from their source facts.
Instead, retrieval uses source_memory_ids to traverse:
- Entity connections: observation → source_memory_ids → unit_entities
- Semantic similarity: observations have their own embeddings
- Temporal proximity: observations have their own temporal fields
Note: We intentionally do NOT copy entity links (unit_entities) to observations.
Instead, the retriever traverses through source_memory_ids to find entity
connections. This avoids duplicating entity data and ensures observations
are connected via their source facts' entity relationships.
This avoids data duplication and ensures observations are always
connected via their source facts' relationships.
Note: Uses EXISTS checks to handle the case where source memory was deleted
by a concurrent operation between fetching and link creation.
The memory_id and observation_id parameters are kept for interface
compatibility but no links are created.
"""
mu_table = fq_table("memory_units")
ml_table = fq_table("memory_links")
# 1. Bidirectional link between memory and observation
# Only insert if both units exist (handles concurrent deletion)
await conn.execute(
f"""
INSERT INTO {ml_table} (from_unit_id, to_unit_id, link_type, weight)
SELECT $1, $2, 'semantic', 1.0
WHERE EXISTS (SELECT 1 FROM {mu_table} WHERE id = $1)
AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = $2)
ON CONFLICT DO NOTHING
""",
memory_id,
observation_id,
)
await conn.execute(
f"""
INSERT INTO {ml_table} (from_unit_id, to_unit_id, link_type, weight)
SELECT $1, $2, 'semantic', 1.0
WHERE EXISTS (SELECT 1 FROM {mu_table} WHERE id = $1)
AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = $2)
ON CONFLICT DO NOTHING
""",
observation_id,
memory_id,
)
# 2. Copy outgoing memory_links from source memory to observation
# If source memory links to X, observation should also link to X
await conn.execute(
f"""
INSERT INTO {ml_table} (from_unit_id, to_unit_id, link_type, entity_id, weight)
SELECT $1, ml.to_unit_id, ml.link_type, ml.entity_id, ml.weight
FROM {ml_table} ml
WHERE ml.from_unit_id = $2 AND ml.to_unit_id != $1
AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = $1)
AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = ml.to_unit_id)
ON CONFLICT DO NOTHING
""",
observation_id,
memory_id,
)
# 3. Copy incoming memory_links from source memory to observation
# If X links to source memory, X should also link to observation
await conn.execute(
f"""
INSERT INTO {ml_table} (from_unit_id, to_unit_id, link_type, entity_id, weight)
SELECT ml.from_unit_id, $1, ml.link_type, ml.entity_id, ml.weight
FROM {ml_table} ml
WHERE ml.to_unit_id = $2 AND ml.from_unit_id != $1
AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = $1)
AND EXISTS (SELECT 1 FROM {mu_table} WHERE id = ml.from_unit_id)
ON CONFLICT DO NOTHING
""",
observation_id,
memory_id,
)
# Note: Entity links (unit_entities) are NOT copied to observations.
# The retriever uses source_memory_ids to traverse through source facts'
# entity connections, avoiding data duplication.
# No links are created - observations rely on source_memory_ids for traversal
pass
async def _find_related_observations(
@@ -674,7 +711,6 @@ async def _find_related_observations(
async def _consolidate_with_llm(
memory_engine: "MemoryEngine",
fact_text: str,
fact_tags: list[str],
observations: list[dict[str, Any]],
mission: str,
) -> list[dict[str, Any]]:
@@ -686,10 +722,14 @@ async def _consolidate_with_llm(
- Related observations exist: compares and returns update/create actions
- Purely ephemeral fact: returns empty array
Note: Tags are NOT handled by the LLM. They are determined algorithmically:
- CREATE: observation inherits source fact's tags
- UPDATE: observation merges source fact's tags with existing tags
Returns:
List of actions, each being:
- {"action": "update", "learning_id": "uuid", "text": "...", "reason": "..."}
- {"action": "create", "tags": [...], "text": "...", "reason": "..."}
- {"action": "create", "text": "...", "reason": "..."}
- [] if fact is purely ephemeral (no durable knowledge)
"""
# Format observations WITH their tags (or "None" if empty)
@@ -713,7 +753,6 @@ Focus on DURABLE knowledge that serves this mission, not ephemeral state.
user_prompt = CONSOLIDATION_USER_PROMPT.format(
mission_section=mission_section,
fact_text=fact_text,
fact_tags=json.dumps(fact_tags),
observations_text=observations_text,
)
@@ -35,38 +35,17 @@ BAD examples:
2. CONTRADICTION: Opposite information about same topic → update with history (e.g., "used to X, now Y")
3. UPDATE: New state replacing old state → update with history
## TAG ROUTING RULES:
Tags define visibility scopes. The fact and each observation have tags (can be empty = global).
| Fact Tags | Obs Tags | Action |
|-----------|----------|--------|
| [alice] | [alice] | UPDATE the observation (same scope) |
| [alice] | [] | UPDATE the observation (global absorbs all scopes) |
| [alice] | [bob] | CREATE new untagged observation (cross-scope insight) |
| [] | [alice] | UPDATE the observation (untagged facts can update any scope) |
| [] | [] | UPDATE the observation (global to global) |
When NO existing observation matches the fact's topic: CREATE new observation with fact's tags.
## MULTIPLE ACTIONS:
One fact can trigger MULTIPLE actions. For example:
- Update a scoped observation [alice] about pizza preferences
- AND update a global observation [] about pizza in general
Output an ARRAY of actions (can be empty, one, or many).
## CRITICAL RULES:
- NEVER merge facts about DIFFERENT people
- NEVER merge unrelated topics (food preferences vs work vs hobbies)
- When merging contradictions, capture the CHANGE (before → after)
- Keep observations focused on ONE specific topic per person
- Cross-scope insights (alice's fact about bob's topic) become UNTAGGED (global)
- The "text" field MUST contain durable knowledge, not ephemeral state"""
- The "text" field MUST contain durable knowledge, not ephemeral state
- Do NOT include "tags" in output - tags are handled automatically"""
CONSOLIDATION_USER_PROMPT = """Analyze this new fact and consolidate into knowledge.
{mission_section}
NEW FACT: {fact_text}
FACT TAGS: {fact_tags}
EXISTING OBSERVATIONS:
{observations_text}
@@ -76,16 +55,15 @@ Instructions:
2. Then compare with existing observations:
- If an observation covers the same topic: UPDATE it with the new knowledge
- If no observation covers the topic: CREATE a new one
- If fact is about different scope: apply tag routing rules
Output JSON array of actions (ALWAYS an array, even for single action):
[
{{"action": "update", "learning_id": "uuid", "text": "updated durable knowledge", "reason": "..."}},
{{"action": "create", "tags": ["tag"], "text": "new durable knowledge", "reason": "..."}}
{{"action": "create", "text": "new durable knowledge", "reason": "..."}}
]
If NO consolidation is needed (fact is purely ephemeral with no durable knowledge):
[]
If no observations exist and fact contains durable knowledge:
[{{"action": "create", "tags": {fact_tags}, "text": "durable knowledge text", "reason": "new topic"}}]"""
[{{"action": "create", "text": "durable knowledge text", "reason": "new topic"}}]"""
@@ -163,11 +163,101 @@ 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
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 +270,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):
@@ -151,10 +151,75 @@ 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
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 +228,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):
@@ -3580,6 +3580,16 @@ class MemoryEngine(MemoryEngineInterface):
if directives:
logger.info(f"[REFLECT {reflect_id}] Loaded {len(directives)} directives")
# Check if the bank has any mental models
async with pool.acquire() as conn:
mental_model_count = await conn.fetchval(
f"SELECT COUNT(*) FROM {fq_table('mental_models')} WHERE bank_id = $1",
bank_id,
)
has_mental_models = mental_model_count > 0
if has_mental_models:
logger.info(f"[REFLECT {reflect_id}] Bank has {mental_model_count} mental models")
# Run the agent
agent_result = await run_reflect_agent(
llm_config=self._reflect_llm_config,
@@ -3595,6 +3605,8 @@ class MemoryEngine(MemoryEngineInterface):
max_tokens=max_tokens,
response_schema=response_schema,
directives=directives,
has_mental_models=has_mental_models,
budget=effective_budget,
)
total_time = time.time() - reflect_start
@@ -80,6 +80,18 @@ def _is_done_tool(name: str) -> bool:
# Pattern to match done() call as text - handles done({...}) with nested JSON
_DONE_CALL_PATTERN = re.compile(r"done\s*\(\s*\{.*$", re.DOTALL)
# Patterns for leaked structured output in the answer field
_LEAKED_JSON_SUFFIX = re.compile(
r'\s*```(?:json)?\s*\{[^}]*(?:"(?:observation_ids|memory_ids|mental_model_ids)"|\})\s*```\s*$',
re.DOTALL | re.IGNORECASE,
)
_LEAKED_JSON_OBJECT = re.compile(
r'\s*\{[^{]*"(?:observation_ids|memory_ids|mental_model_ids|answer)"[^}]*\}\s*$', re.DOTALL
)
_TRAILING_IDS_PATTERN = re.compile(
r"\s*(?:observation_ids|memory_ids|mental_model_ids)\s*[=:]\s*\[.*?\]\s*$", re.DOTALL | re.IGNORECASE
)
def _clean_answer_text(text: str) -> str:
"""Clean up answer text by removing any done() tool call syntax.
@@ -92,6 +104,33 @@ def _clean_answer_text(text: str) -> str:
return cleaned if cleaned else text
def _clean_done_answer(text: str) -> str:
"""Clean up the answer field from a done() tool call.
Some LLMs leak structured output patterns into the answer text, such as:
- JSON code blocks with observation_ids/memory_ids at the end
- Raw JSON objects with these fields
- Plain text like "observation_ids: [...]"
This cleans those patterns while preserving the actual answer content.
"""
if not text:
return text
cleaned = text
# Remove leaked JSON in code blocks at the end
cleaned = _LEAKED_JSON_SUFFIX.sub("", cleaned).strip()
# Remove leaked raw JSON objects at the end
cleaned = _LEAKED_JSON_OBJECT.sub("", cleaned).strip()
# Remove trailing ID patterns
cleaned = _TRAILING_IDS_PATTERN.sub("", cleaned).strip()
return cleaned if cleaned else text
async def _generate_structured_output(
answer: str,
response_schema: dict,
@@ -141,35 +180,55 @@ async def _generate_structured_output(
fields[field_name] = (field_type, default)
if not fields:
return None
logger.warning(f"[REFLECT {reflect_id}] No fields found in response_schema, skipping structured output")
return None, 0, 0
DynamicModel = create_model("StructuredResponse", **fields)
# Include the full schema in the prompt for better LLM guidance
schema_str = json.dumps(response_schema, indent=2)
# Build field descriptions for the prompt
field_descriptions = []
for field_name, field_schema in schema_props.items():
field_type = field_schema.get("type", "string")
field_desc = field_schema.get("description", "")
is_required = field_name in required_fields
req_marker = " (REQUIRED)" if is_required else " (optional)"
field_descriptions.append(f"- {field_name} ({field_type}){req_marker}: {field_desc}")
fields_text = "\n".join(field_descriptions)
# Call LLM with the answer to extract structured data
structured_prompt = f"""Based on this answer, extract the information into the requested structured format.
structured_prompt = f"""Your task is to extract specific information from the answer below and format it as JSON.
Answer: {answer}
ANSWER TO EXTRACT FROM:
\"\"\"
{answer}
\"\"\"
JSON Schema to follow:
REQUIRED OUTPUT FORMAT - Extract the following fields from the answer above:
{fields_text}
JSON Schema:
```json
{schema_str}
```
Return ONLY a valid JSON object that matches this exact schema. Pay special attention to field types:
- "type": "array" means the value must be a JSON array/list, NOT a string
- "type": "string" means the value must be a string
- "type": "object" means the value must be a JSON object
INSTRUCTIONS:
1. Read the answer carefully and identify the information that matches each field
2. Extract the ACTUAL content from the answer - do NOT leave fields empty if information is present
3. For string fields: use the exact text or a clear summary from the answer
4. For array fields: return a JSON array (e.g., ["item1", "item2"]), NOT a string
5. For required fields: you MUST provide a value extracted from the answer
6. Return ONLY the JSON object, no explanation
Do not include any explanation, only the JSON object."""
OUTPUT:"""
structured_result, usage = await llm_config.call(
messages=[
{
"role": "system",
"content": "Extract structured data from the given answer. Return only valid JSON matching the provided schema exactly.",
"content": "You are a precise data extraction assistant. Extract information from text and return it as valid JSON matching the provided schema. Always extract actual content - never return empty strings for required fields if information is available.",
},
{"role": "user", "content": structured_prompt},
],
@@ -188,6 +247,12 @@ Do not include any explanation, only the JSON object."""
# Try to parse as JSON
structured_output = json.loads(str(structured_result))
# Validate that required fields have non-empty values
for field_name in required_fields:
value = structured_output.get(field_name)
if value is None or value == "" or value == []:
logger.warning(f"[REFLECT {reflect_id}] Required field '{field_name}' is empty in structured output")
logger.info(f"[REFLECT {reflect_id}] Generated structured output with {len(structured_output)} fields")
return structured_output, usage.input_tokens, usage.output_tokens
@@ -722,7 +787,9 @@ async def _process_done_tool(
"""Process the done tool call and return the result."""
args = done_call.arguments
answer = args.get("answer", "").strip()
# Extract and clean the answer - some LLMs leak structured output into the answer text
raw_answer = args.get("answer", "").strip()
answer = _clean_done_answer(raw_answer) if raw_answer else ""
if not answer:
answer = "No answer provided."
@@ -432,34 +432,15 @@ def _chunk_conversation(turns: list[dict], max_chars: int) -> list[str]:
# FACT EXTRACTION PROMPTS
# =============================================================================
# Concise extraction prompt (default) - selective, high-quality facts
CONCISE_FACT_EXTRACTION_PROMPT = """Extract SIGNIFICANT facts from text. Be SELECTIVE - only extract facts worth remembering long-term.
# Base prompt template (shared by concise and custom modes)
# Uses {extraction_guidelines} placeholder for mode-specific instructions
_BASE_FACT_EXTRACTION_PROMPT = """Extract SIGNIFICANT facts from text. Be SELECTIVE - only extract facts worth remembering long-term.
LANGUAGE REQUIREMENT: Detect the language of the input text. All extracted facts, entity names, descriptions, and other output MUST be in the SAME language as the input. Do not translate to another language.
{fact_types_instruction}
══════════════════════════════════════════════════════════════════════════
SELECTIVITY - CRITICAL (Reduces 90% of unnecessary output)
══════════════════════════════════════════════════════════════════════════
ONLY extract facts that are:
✅ Personal info: names, relationships, roles, background
✅ Preferences: likes, dislikes, habits, interests (e.g., "Alice likes coffee")
✅ Significant events: milestones, decisions, achievements, changes
✅ Plans/goals: future intentions, deadlines, commitments
✅ Expertise: skills, knowledge, certifications, experience
✅ Important context: projects, problems, constraints
✅ Sensory/emotional details: feelings, sensations, perceptions that provide context
✅ Observations: descriptions of people, places, things with specific details
DO NOT extract:
❌ Generic greetings: "how are you", "hello", pleasantries without substance
❌ Pure filler: "thanks", "sounds good", "ok", "got it", "sure"
❌ Process chatter: "let me check", "one moment", "I'll look into it"
❌ Repeated info: if already stated, don't extract again
CONSOLIDATE related statements into ONE fact when possible.
{extraction_guidelines}
══════════════════════════════════════════════════════════════════════════
FACT FORMAT - BE CONCISE
@@ -507,7 +488,33 @@ ENTITIES
══════════════════════════════════════════════════════════════════════════
Include: people names, organizations, places, key objects, abstract concepts (career, friendship, etc.)
Always include "user" when fact is about the user.
Always include "user" when fact is about the user.{examples}"""
# Concise mode guidelines
_CONCISE_GUIDELINES = """══════════════════════════════════════════════════════════════════════════
SELECTIVITY - CRITICAL (Reduces 90% of unnecessary output)
══════════════════════════════════════════════════════════════════════════
ONLY extract facts that are:
✅ Personal info: names, relationships, roles, background
✅ Preferences: likes, dislikes, habits, interests (e.g., "Alice likes coffee")
✅ Significant events: milestones, decisions, achievements, changes
✅ Plans/goals: future intentions, deadlines, commitments
✅ Expertise: skills, knowledge, certifications, experience
✅ Important context: projects, problems, constraints
✅ Sensory/emotional details: feelings, sensations, perceptions that provide context
✅ Observations: descriptions of people, places, things with specific details
DO NOT extract:
❌ Generic greetings: "how are you", "hello", pleasantries without substance
❌ Pure filler: "thanks", "sounds good", "ok", "got it", "sure"
❌ Process chatter: "let me check", "one moment", "I'll look into it"
❌ Repeated info: if already stated, don't extract again
CONSOLIDATE related statements into ONE fact when possible."""
# Concise mode examples
_CONCISE_EXAMPLES = """
══════════════════════════════════════════════════════════════════════════
EXAMPLES
@@ -533,6 +540,20 @@ QUALITY OVER QUANTITY
Ask: "Would this be useful to recall in 6 months?" If no, skip it."""
# Assembled concise prompt (backward compatible - exact same output as before)
CONCISE_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
fact_types_instruction="{fact_types_instruction}",
extraction_guidelines=_CONCISE_GUIDELINES,
examples=_CONCISE_EXAMPLES,
)
# Custom prompt uses same base but without examples
CUSTOM_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
fact_types_instruction="{fact_types_instruction}",
extraction_guidelines="{custom_instructions}",
examples="", # No examples for custom mode
)
# Verbose extraction prompt - detailed, comprehensive facts (legacy mode)
VERBOSE_FACT_EXTRACTION_PROMPT = """Extract facts from text into structured format with FIVE required dimensions - BE EXTREMELY DETAILED.
@@ -680,6 +701,12 @@ async def _extract_facts_from_chunk(
Note: event_date parameter is kept for backward compatibility but not used in prompt.
The LLM extracts temporal information from the context string instead.
"""
import logging
from openai import BadRequestError
logger = logging.getLogger(__name__)
memory_bank_context = f"\n- Your name: {agent_name}" if agent_name and extract_opinions else ""
# Determine which fact types to extract based on the flag
@@ -698,13 +725,27 @@ async def _extract_facts_from_chunk(
extract_causal_links = config.retain_extract_causal_links
# Select base prompt based on extraction mode
if extraction_mode == "verbose":
if extraction_mode == "custom":
# Custom mode: inject user-provided guidelines
if not config.retain_custom_instructions:
logger.warning(
"extraction_mode='custom' but HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS not set. "
"Falling back to 'concise' mode."
)
base_prompt = CONCISE_FACT_EXTRACTION_PROMPT
prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
else:
base_prompt = CUSTOM_FACT_EXTRACTION_PROMPT
prompt = base_prompt.format(
fact_types_instruction=fact_types_instruction,
custom_instructions=config.retain_custom_instructions,
)
elif extraction_mode == "verbose":
base_prompt = VERBOSE_FACT_EXTRACTION_PROMPT
prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
else:
base_prompt = CONCISE_FACT_EXTRACTION_PROMPT
# Format the prompt with fact types instruction
prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
# Build the full prompt with or without causal relationships section
# Select appropriate response schema based on extraction mode and causal links
@@ -717,12 +758,6 @@ async def _extract_facts_from_chunk(
else:
response_schema = FactExtractionResponseNoCausal
import logging
from openai import BadRequestError
logger = logging.getLogger(__name__)
# Retry logic for JSON validation errors
max_retries = 2
last_error = None
+1
View File
@@ -213,6 +213,7 @@ def main():
retain_chunk_size=config.retain_chunk_size,
retain_extract_causal_links=config.retain_extract_causal_links,
retain_extraction_mode=config.retain_extraction_mode,
retain_custom_instructions=config.retain_custom_instructions,
retain_observations_async=config.retain_observations_async,
enable_observations=config.enable_observations,
consolidation_batch_size=config.consolidation_batch_size,
+33 -22
View File
@@ -268,8 +268,16 @@ class TestConsolidationIntegration:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_consolidation_creates_memory_links(self, memory: MemoryEngine, request_context):
"""Test that observations get bidirectional links to their source memories."""
async def test_consolidation_uses_source_memory_ids(self, memory: MemoryEngine, request_context):
"""Test that observations use source_memory_ids (not memory_links) to track source facts.
Observations rely on source_memory_ids for traversal:
- Entity connections: observation → source_memory_ids → unit_entities
- Semantic similarity: observations have their own embeddings
- Temporal proximity: observations have their own temporal fields
No memory_links are created between observations and their source facts.
"""
bank_id = f"test-consolidation-links-{uuid.uuid4().hex[:8]}"
# Create the bank
@@ -282,7 +290,7 @@ class TestConsolidationIntegration:
request_context=request_context,
)
# Check memory_links between observation and source memory
# Check that observation has source_memory_ids but no memory_links
async with memory._pool.acquire() as conn:
observation = await conn.fetchrow(
"""
@@ -294,32 +302,35 @@ class TestConsolidationIntegration:
bank_id,
)
if observation and observation["source_memory_ids"]:
if observation:
# Observation should have source_memory_ids
assert observation["source_memory_ids"] is not None, "Observation should have source_memory_ids"
assert len(observation["source_memory_ids"]) > 0, "Observation should have at least one source memory"
source_memory_id = observation["source_memory_ids"][0]
# Check that bidirectional links exist
link_from_memory = await conn.fetchrow(
# Verify the source memory exists
source_memory = await conn.fetchrow(
"""
SELECT * FROM memory_links
WHERE from_unit_id = $1 AND to_unit_id = $2
SELECT id, fact_type FROM memory_units WHERE id = $1
""",
source_memory_id,
observation["id"],
)
link_to_memory = await conn.fetchrow(
"""
SELECT * FROM memory_links
WHERE from_unit_id = $1 AND to_unit_id = $2
""",
observation["id"],
source_memory_id,
)
assert source_memory is not None, "Source memory should exist"
assert source_memory["fact_type"] in ("world", "experience"), "Source should be a fact"
# Both directions should have links
assert link_from_memory is not None, "Expected link from source memory to observation"
assert link_to_memory is not None, "Expected link from observation to source memory"
assert link_from_memory["link_type"] == "semantic"
assert link_to_memory["link_type"] == "semantic"
# No memory_links should exist between observation and source
# (observations rely on source_memory_ids for traversal)
links = await conn.fetch(
"""
SELECT * FROM memory_links
WHERE (from_unit_id = $1 AND to_unit_id = $2)
OR (from_unit_id = $2 AND to_unit_id = $1)
""",
source_memory_id,
observation["id"],
)
assert len(links) == 0, "No memory_links should exist between observation and source"
# 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
+15 -4
View File
@@ -8,9 +8,20 @@ populated from the summary for backwards compatibility.
import pytest
from hindsight_api.engine.memory_engine import Budget
from hindsight_api import RequestContext
from hindsight_api.config import get_config
from datetime import datetime, timezone
@pytest.fixture
def disable_observations():
"""Disable observations for a specific test."""
config = get_config()
original_value = config.enable_observations
config.enable_observations = False
yield
config.enable_observations = original_value
@pytest.mark.asyncio
async def test_entity_extraction_on_retain(memory, request_context):
"""
@@ -370,12 +381,12 @@ async def test_get_entity_state(memory, request_context):
@pytest.mark.asyncio
async def test_observation_fact_type_in_database(memory, request_context):
async def test_observation_fact_type_in_database(memory, request_context, disable_observations):
"""
Test that observations are NOT stored as memory_units with fact_type='observation'.
Test that when observations are disabled, no observation records are created.
NOTE: Observations are now handled via mental models, not as memory_units
or entity summaries.
When enable_observations=False, consolidation does not run and no
memory_units with fact_type='observation' should exist.
"""
bank_id = f"test_obs_db_{datetime.now(timezone.utc).timestamp()}"
+74
View File
@@ -14,6 +14,7 @@ from hindsight_api.engine.reflect.agent import (
_normalize_tool_name,
_is_done_tool,
_clean_answer_text,
_clean_done_answer,
run_reflect_agent,
)
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
@@ -61,6 +62,79 @@ class TestCleanAnswerText:
assert cleaned == "Summary of findings."
class TestCleanDoneAnswer:
"""Test cleanup of answer field from done() tool call that leaks structured output."""
def test_clean_answer_with_leaked_json_code_block(self):
"""Answer with leaked JSON code block at the end should be cleaned."""
text = '''The user's favorite color is blue.
```json
{"observation_ids": ["obs-1", "obs-2"]}
```'''
cleaned = _clean_done_answer(text)
assert cleaned == "The user's favorite color is blue."
assert "observation_ids" not in cleaned
def test_clean_answer_with_memory_ids_code_block(self):
"""Answer with leaked memory_ids JSON code block should be cleaned."""
text = '''Here is the answer.
```json
{"memory_ids": ["mem-1"]}
```'''
cleaned = _clean_done_answer(text)
assert cleaned == "Here is the answer."
def test_clean_answer_with_raw_json_object(self):
"""Answer with raw JSON object containing IDs at the end should be cleaned."""
text = 'The answer is 42. {"observation_ids": ["obs-1"]}'
cleaned = _clean_done_answer(text)
assert cleaned == "The answer is 42."
def test_clean_answer_with_trailing_ids_pattern(self):
"""Answer with 'observation_ids: [...]' pattern at the end should be cleaned."""
text = "This is the answer.\n\nobservation_ids: [\"obs-1\", \"obs-2\"]"
cleaned = _clean_done_answer(text)
assert cleaned == "This is the answer."
def test_clean_answer_with_memory_ids_equals(self):
"""Answer with 'memory_ids = [...]' pattern at the end should be cleaned."""
text = "Answer text here.\nmemory_ids = [\"mem-1\"]"
cleaned = _clean_done_answer(text)
assert cleaned == "Answer text here."
def test_clean_normal_answer_unchanged(self):
"""Normal answer without leaked output should be unchanged."""
text = "This is a normal answer about observation strategies."
cleaned = _clean_done_answer(text)
assert cleaned == text
def test_clean_empty_answer(self):
"""Empty answer should return empty."""
assert _clean_done_answer("") == ""
def test_clean_answer_with_observation_word_in_content(self):
"""The word 'observation' in regular text should not be stripped."""
text = "Based on my observation, the user prefers dark mode."
cleaned = _clean_done_answer(text)
assert cleaned == text
def test_clean_answer_multiline_with_markdown(self):
"""Answer with markdown and leaked JSON at end should clean only the leak."""
text = '''Summary:
- Point 1
- Point 2
```json
{"mental_model_ids": ["mm-1"]}
```'''
cleaned = _clean_done_answer(text)
assert "Point 1" in cleaned
assert "Point 2" in cleaned
assert "mental_model_ids" not in cleaned
class TestToolNameNormalization:
"""Test tool name normalization for various LLM output formats."""
+114
View File
@@ -2082,3 +2082,117 @@ def test_recall_result_model_empty_construction():
assert result.chunks == {}, "Should have empty chunks"
logger.info("✓ RecallResult empty construction works correctly")
@pytest.mark.asyncio
async def test_custom_extraction_mode():
"""
Test that custom extraction mode uses custom guidelines from env variable.
This test verifies that when HINDSIGHT_API_RETAIN_EXTRACTION_MODE=custom and
HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS is set, the fact extraction uses the
custom guidelines while keeping structural parts intact.
"""
import os
from hindsight_api import LLMConfig
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
from hindsight_api.config import clear_config_cache
# Save original env vars
original_mode = os.getenv("HINDSIGHT_API_RETAIN_EXTRACTION_MODE")
original_instructions = os.getenv("HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS")
try:
# Set custom extraction mode with challenging language-specific guidelines
os.environ["HINDSIGHT_API_RETAIN_EXTRACTION_MODE"] = "custom"
os.environ["HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS"] = """ONLY extract facts that are in ITALIAN language.
DO NOT extract:
❌ Facts in English
❌ Facts in any other language besides Italian
If the text contains both Italian and English content, extract ONLY the Italian facts."""
# Clear config cache to pick up new env vars
clear_config_cache()
# Test content with BOTH Italian (should extract) and English (should NOT extract) facts
# This is a much harder test than filtering greetings
text = """
The team discussed the new architecture. We will use microservices.
Il database PostgreSQL ha ridotto la latenza delle query del 60%.
Alice ha suggerito di usare il connection pooling per migliorare le prestazioni.
Bob mentioned that the API endpoint is ready for testing.
The deployment pipeline has been updated to use Kubernetes.
Marco ha completato la revisione del codice e ha approvato le modifiche.
Il sistema di autenticazione è stato migrato a OAuth 2.0.
"""
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
context="team meeting notes",
llm_config=llm_config,
agent_name="TestUser"
)
logger.info(f"\nExtracted {len(facts)} facts with custom mode (Italian only):")
for i, fact in enumerate(facts):
logger.info(f" {i+1}. {fact.fact}")
assert len(facts) > 0, "Should extract at least one Italian fact"
# All facts text
all_facts_text = " ".join([f.fact for f in facts])
# Should HAVE Italian content
italian_keywords = ["postgresql", "latenza", "query", "alice", "connection pooling", "prestazioni",
"marco", "revisione", "codice", "autenticazione", "oauth"]
has_italian = any(keyword in all_facts_text.lower() for keyword in italian_keywords)
assert has_italian, f"Should extract Italian facts. Got: {all_facts_text}"
# Should NOT have English-only content
# These are facts that appear ONLY in English sections
english_only_keywords = ["microservices", "bob", "api endpoint", "testing", "deployment pipeline", "kubernetes"]
# Check if facts contain English-only content (this would be wrong)
facts_lower = all_facts_text.lower()
found_english_only = [kw for kw in english_only_keywords if kw in facts_lower]
if found_english_only:
logger.warning(f"⚠ Found English-only keywords in facts: {found_english_only}")
logger.warning(f" Facts: {all_facts_text}")
logger.warning(f" This may indicate the LLM is not strictly following language-specific custom guidelines")
# Log but don't fail - LLM behavior can vary
else:
logger.info("✓ Successfully extracted only Italian facts, ignored English facts")
# At least verify we have some Italian indicators
italian_indicators = ["latenza", "prestazioni", "revisione", "codice", "autenticazione"]
italian_count = sum(1 for ind in italian_indicators if ind in facts_lower)
assert italian_count >= 1, \
f"Should extract facts with Italian words. Found {italian_count} Italian indicators in: {all_facts_text}"
logger.info("✓ Custom extraction mode works with language-specific guidelines")
logger.info(f"✓ Extracted {len(facts)} Italian facts, found {italian_count} Italian indicators")
finally:
# Restore original env vars
if original_mode is not None:
os.environ["HINDSIGHT_API_RETAIN_EXTRACTION_MODE"] = original_mode
else:
os.environ.pop("HINDSIGHT_API_RETAIN_EXTRACTION_MODE", None)
if original_instructions is not None:
os.environ["HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS"] = original_instructions
else:
os.environ.pop("HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS", None)
# Clear cache again to restore original config
clear_config_cache()
+13 -7
View File
@@ -633,7 +633,12 @@ async def test_student_tracking_visibility(api_client):
@pytest.mark.asyncio
async def test_list_tags_returns_all_tags(api_client):
"""Test that list_tags returns all unique tags with counts."""
"""Test that list_tags returns all unique tags with counts.
Note: list_tags counts all memory units including observations.
Observations inherit tags from their source facts (for visibility security),
so counts may be higher than the number of stored memories.
"""
bank_id = f"list_tags_test_{datetime.now().timestamp()}"
# Store memories with various tags
@@ -662,18 +667,19 @@ async def test_list_tags_returns_all_tags(api_client):
assert "limit" in result
assert "offset" in result
# Verify tags and counts
# Verify tags exist with at least the expected counts
# Note: Counts may be higher due to observations inheriting source fact tags
tags_map = {item["tag"]: item["count"] for item in result["items"]}
assert "user:alice" in tags_map
assert tags_map["user:alice"] == 3 # 3 memories have this tag
assert tags_map["user:alice"] >= 3 # At least 3 memories have this tag
assert "user:bob" in tags_map
assert tags_map["user:bob"] == 1
assert tags_map["user:bob"] >= 1
assert "session:123" in tags_map
assert tags_map["session:123"] == 1
assert tags_map["session:123"] >= 1
assert "session:456" in tags_map
assert tags_map["session:456"] == 1
assert tags_map["session:456"] >= 1
assert result["total"] == 4 # 4 unique tags
assert result["total"] >= 4 # At least 4 unique tags
@pytest.mark.asyncio
@@ -15,8 +15,6 @@ The framework supports two answer generation patterns:
2. Integrated: Answer generator performs its own retrieval (e.g., think API)
- Indicated by needs_external_search() returning False
- Skips the search step for efficiency
Optional --include-mental-models flag enables returning mental models in recall results.
"""
import asyncio
@@ -536,8 +534,6 @@ class BenchmarkRunner:
max_tokens: int = 4096,
question_date: Optional[datetime] = None,
question_type: Optional[str] = None,
include_mental_models: bool = False,
only_mental_models: bool = False,
) -> Tuple[str, str, List[Dict], Dict[str, Dict]]:
"""
Answer a question using memory retrieval.
@@ -549,8 +545,6 @@ class BenchmarkRunner:
max_tokens: Maximum tokens to retrieve
question_date: Date when the question was asked (for temporal filtering)
question_type: Question category/type (e.g., 'multi-session', 'temporal-reasoning')
include_mental_models: If True, include mental models in recall results
only_mental_models: If True, only retrieve mental models (no facts)
Returns:
Tuple of (answer, reasoning, retrieved_memories, chunks)
@@ -565,26 +559,16 @@ class BenchmarkRunner:
import time
recall_start_time = time.time()
# Build fact_types based on what's requested
if only_mental_models:
# Only retrieve mental models
fact_types = ["mental_model"]
elif include_mental_models:
# Retrieve facts AND mental models
fact_types = ["world", "experience", "mental_model"]
else:
# Only retrieve facts
fact_types = ["world", "experience"]
# Use default fact types (no filtering)
search_result = await self.memory.recall_async(
bank_id=agent_id,
query=question,
budget=budget,
max_tokens=max_tokens,
fact_type=fact_types,
question_date=question_date,
include_entities=not only_mental_models, # Skip entities when only mental models
include_entities=True,
max_entity_tokens=2048,
include_chunks=True, # Always include chunks (mental models fetch from source memories)
include_chunks=True,
request_context=RequestContext(),
)
recall_time = time.time() - recall_start_time
@@ -641,16 +625,12 @@ class BenchmarkRunner:
max_tokens: int,
max_questions: Optional[int] = None,
semaphore: asyncio.Semaphore = None,
include_mental_models: bool = False,
only_mental_models: bool = False,
) -> List[Dict]:
"""
Evaluate QA task with parallel question processing.
Args:
semaphore: Semaphore to limit concurrent question processing
include_mental_models: If True, include mental models in recall results
only_mental_models: If True, only retrieve mental models (no facts)
Returns:
List of QA results
@@ -695,8 +675,6 @@ class BenchmarkRunner:
max_tokens,
question_date,
category,
include_mental_models,
only_mental_models,
)
# Remove embeddings from retrieved memories to reduce file size
@@ -875,8 +853,6 @@ class BenchmarkRunner:
question_semaphore: asyncio.Semaphore,
eval_semaphore_size: int = 8,
clear_this_agent: bool = True,
include_mental_models: bool = False,
only_mental_models: bool = False,
) -> Dict:
"""
Process a single item (ingest + evaluate).
@@ -884,8 +860,6 @@ class BenchmarkRunner:
Args:
clear_this_agent: Whether to clear this agent's data before ingesting.
Set to False to skip clearing (e.g., when agent_id is shared and already cleared)
include_mental_models: If True, include mental models in recall results and wait for consolidation after ingestion
only_mental_models: If True, only retrieve mental models (no facts)
Returns:
Result dict with metrics
@@ -902,11 +876,10 @@ class BenchmarkRunner:
await self.memory.delete_bank(agent_id, request_context=RequestContext())
console.print(f" [green]✓[/green] Cleared '{agent_id}' agent data")
# Ingest conversation (wait for consolidation if mental models are requested)
# Ingest conversation
step += 1
console.print(f" [{step}] Ingesting conversation (batch mode)...")
wait_for_consolidation = include_mental_models or only_mental_models
num_sessions = await self.ingest_conversation(item, agent_id, wait_for_consolidation=wait_for_consolidation)
num_sessions = await self.ingest_conversation(item, agent_id, wait_for_consolidation=False)
console.print(f" [green]✓[/green] Ingested {num_sessions} sessions")
else:
num_sessions = -1
@@ -923,8 +896,6 @@ class BenchmarkRunner:
max_tokens,
max_questions_per_item,
question_semaphore,
include_mental_models,
only_mental_models,
)
# Calculate metrics
@@ -956,8 +927,6 @@ class BenchmarkRunner:
max_concurrent_items: int = 1, # Max concurrent items (conversations) to process in parallel
output_path: Optional[Path] = None, # Path to save results incrementally
merge_with_existing: bool = False, # Whether to merge with existing results
include_mental_models: bool = False, # If True, include mental models in recall results
only_mental_models: bool = False, # If True, only retrieve mental models (no facts)
) -> Dict[str, Any]:
"""
Run the full benchmark evaluation.
@@ -977,8 +946,6 @@ class BenchmarkRunner:
separate_ingestion_phase: If True, ingest all data first, then evaluate all questions (single agent)
filln: If True, only process items where the agent has no indexed data yet
max_concurrent_items: Max concurrent items to process in parallel (requires clear_agent_per_item=True)
include_mental_models: If True, include mental models in recall results and wait for consolidation after ingestion.
only_mental_models: If True, only retrieve mental models (no facts). Implies waiting for consolidation.
Returns:
Dict with complete benchmark results
@@ -1020,8 +987,6 @@ class BenchmarkRunner:
eval_semaphore_size,
output_path,
merge_with_existing,
include_mental_models,
only_mental_models,
)
else:
# Original approach: process each item independently
@@ -1039,8 +1004,6 @@ class BenchmarkRunner:
max_concurrent_items,
output_path,
merge_with_existing,
include_mental_models,
only_mental_models,
)
async def _run_single_phase(
@@ -1058,8 +1021,6 @@ class BenchmarkRunner:
max_concurrent_items: int = 1,
output_path: Optional[Path] = None,
merge_with_existing: bool = False,
include_mental_models: bool = False,
only_mental_models: bool = False,
) -> Dict[str, Any]:
"""Original single-phase approach: process each item independently."""
# Create semaphore for question processing
@@ -1081,8 +1042,6 @@ class BenchmarkRunner:
max_concurrent_items,
output_path,
merge_with_existing,
include_mental_models,
only_mental_models,
)
else:
# Sequential item processing (original behavior)
@@ -1099,8 +1058,6 @@ class BenchmarkRunner:
filln,
output_path,
merge_with_existing,
include_mental_models,
only_mental_models,
)
# Calculate overall metrics
@@ -1136,8 +1093,6 @@ class BenchmarkRunner:
filln: bool,
output_path: Optional[Path] = None,
merge_with_existing: bool = False,
include_mental_models: bool = False,
only_mental_models: bool = False,
) -> List[Dict]:
"""Process items sequentially (original behavior)."""
all_results = []
@@ -1185,8 +1140,6 @@ class BenchmarkRunner:
question_semaphore,
eval_semaphore_size,
clear_this_agent,
include_mental_models,
only_mental_models,
)
# Replace existing result or append new one
@@ -1218,8 +1171,6 @@ class BenchmarkRunner:
max_concurrent_items: int,
output_path: Optional[Path] = None,
merge_with_existing: bool = False,
include_mental_models: bool = False,
only_mental_models: bool = False,
) -> List[Dict]:
"""Process items in parallel (requires unique agent IDs per item)."""
# Load existing results if merge_with_existing is True
@@ -1264,8 +1215,6 @@ class BenchmarkRunner:
question_semaphore,
eval_semaphore_size,
clear_this_agent=True, # Always clear for parallel processing
include_mental_models=include_mental_models,
only_mental_models=only_mental_models,
)
return result
@@ -1304,17 +1253,11 @@ class BenchmarkRunner:
eval_semaphore_size: int,
output_path: Optional[Path] = None,
merge_with_existing: bool = False,
include_mental_models: bool = False,
only_mental_models: bool = False,
) -> Dict[str, Any]:
"""
Two-phase approach: ingest all data into single agent, then evaluate all questions.
More realistic scenario where agent accumulates memories over time.
Args:
include_mental_models: If True, include mental models in recall results and wait for consolidation
only_mental_models: If True, only retrieve mental models (no facts)
"""
# Phase 1: Ingestion
if not skip_ingestion:
@@ -1350,10 +1293,6 @@ class BenchmarkRunner:
)
console.print(f" [green]✓[/green] Ingested {len(all_sessions)} sessions from {len(items)} items")
# Wait for consolidation if mental models are requested
if include_mental_models or only_mental_models:
await self._wait_for_consolidation(agent_id)
else:
console.print("\n[3] Skipping ingestion (using existing data)")
@@ -1380,8 +1319,6 @@ class BenchmarkRunner:
max_tokens,
max_questions_per_item,
question_semaphore,
include_mental_models,
only_mental_models,
)
# Calculate metrics
@@ -278,8 +278,6 @@ async def run_benchmark(
max_questions_per_conv: int = None,
skip_ingestion: bool = False,
use_think: bool = False,
include_mental_models: bool = False,
only_mental_models: bool = False,
conversation: str = None,
api_url: str = None,
max_concurrent_questions_override: int = None,
@@ -294,8 +292,6 @@ async def run_benchmark(
max_questions_per_conv: Maximum questions per conversation (None for all)
skip_ingestion: Whether to skip ingestion and use existing data
use_think: Whether to use the think API instead of search + LLM
include_mental_models: If True, include mental models in recall results and wait for consolidation after ingestion.
only_mental_models: If True, only retrieve mental models (no facts). Implies waiting for consolidation.
conversation: Specific conversation ID to run (e.g., "conv-26")
api_url: Optional API URL to connect to (default: use local memory)
only_failed: If True, only run conversations that have failed questions (is_correct=False)
@@ -403,14 +399,7 @@ async def run_benchmark(
dataset.load = filtered_load
# Determine output filename based on mode
if use_think:
suffix = "_think"
elif only_mental_models:
suffix = "_only_mental_models"
elif include_mental_models:
suffix = "_mental_models"
else:
suffix = ""
suffix = "_think" if use_think else ""
results_filename = f"benchmark_results{suffix}.json"
output_path = Path(__file__).parent / "results" / results_filename
@@ -423,11 +412,7 @@ async def run_benchmark(
# Each conversation gets its own isolated bank
separate_ingestion = False
clear_per_item = True # Use unique agent ID per conversation
if include_mental_models or only_mental_models:
# Mental models requires more time due to consolidation, limit parallelism
concurrent_items = 2
else:
concurrent_items = 3 # Process up to 3 conversations in parallel
concurrent_items = 3 # Process up to 3 conversations in parallel
# Run benchmark with parallel conversation processing
# Each conversation gets its own agent ID (locomo_conv-26, locomo_conv-30, etc.)
@@ -448,8 +433,6 @@ async def run_benchmark(
max_concurrent_items=concurrent_items,
output_path=output_path, # Save results incrementally
merge_with_existing=merge_with_existing,
include_mental_models=include_mental_models, # Include mental models in recall results
only_mental_models=only_mental_models, # Only retrieve mental models (no facts)
)
# Display results (final save already happened incrementally)
@@ -457,16 +440,12 @@ async def run_benchmark(
console.print(f"\n[green]✓[/green] Results saved incrementally to {output_path}")
# Generate markdown table
generate_markdown_table(
results, use_think=use_think, include_mental_models=include_mental_models, only_mental_models=only_mental_models
)
generate_markdown_table(results, use_think=use_think)
return results
def generate_markdown_table(
results: dict, use_think: bool = False, include_mental_models: bool = False, only_mental_models: bool = False
):
def generate_markdown_table(results: dict, use_think: bool = False):
"""
Generate a markdown table with benchmark results.
@@ -484,14 +463,7 @@ def generate_markdown_table(
# Build markdown content
lines = []
if use_think:
mode_str = " (Think Mode)"
elif only_mental_models:
mode_str = " (Only Mental Models Mode)"
elif include_mental_models:
mode_str = " (Mental Models Mode)"
else:
mode_str = ""
mode_str = " (Think Mode)" if use_think else ""
lines.append(f"# LoComo Benchmark Results{mode_str}")
lines.append("")
@@ -542,14 +514,7 @@ def generate_markdown_table(
)
# Write to file with suffix
if use_think:
suffix = "_think"
elif only_mental_models:
suffix = "_only_mental_models"
elif include_mental_models:
suffix = "_mental_models"
else:
suffix = ""
suffix = "_think" if use_think else ""
output_file = Path(__file__).parent / "results" / f"results_table{suffix}.md"
output_file.parent.mkdir(parents=True, exist_ok=True)
output_file.write_text("\n".join(lines))
@@ -592,16 +557,6 @@ if __name__ == "__main__":
action="store_true",
help="Only run conversations that have invalid questions (is_invalid=True). Requires existing results file.",
)
parser.add_argument(
"--include-mental-models",
action="store_true",
help="Include mental models in recall results. This waits for consolidation to complete after ingestion and includes mental models in the recall response.",
)
parser.add_argument(
"--only-mental-models",
action="store_true",
help="Only retrieve mental models (no facts). This waits for consolidation to complete after ingestion and only returns mental models.",
)
args = parser.parse_args()
@@ -615,8 +570,6 @@ if __name__ == "__main__":
max_questions_per_conv=args.max_questions,
skip_ingestion=args.skip_ingestion,
use_think=args.use_think,
include_mental_models=args.include_mental_models,
only_mental_models=args.only_mental_models,
conversation=args.conversation,
api_url=args.api_url,
max_concurrent_questions_override=args.max_concurrent_questions,
@@ -433,8 +433,6 @@ async def run_benchmark(
results_filename: str = "benchmark_results.json",
context_format: str = "json",
source_results: str = None,
include_mental_models: bool = False,
only_mental_models: bool = False,
):
"""
Run the LongMemEval benchmark.
@@ -456,8 +454,6 @@ async def run_benchmark(
results_filename: Filename for results (default: benchmark_results.json). Directory is fixed to results/.
context_format: How to format context for answer generation. "json" (raw JSON) or "structured" (human-readable with facts+chunks).
source_results: Source results file to read failed/invalid questions from (for --only-failed/--only-invalid). Defaults to benchmark_results.json.
include_mental_models: If True, include mental models in recall results and wait for consolidation after ingestion.
only_mental_models: If True, only retrieve mental models (no facts). Implies waiting for consolidation.
"""
from rich.console import Console
@@ -629,10 +625,6 @@ async def run_benchmark(
answer_generator = LongMemEvalAnswerGenerator(context_format=context_format)
# Log context format being used
console.print(f"[blue]Context format: {context_format}[/blue]")
if only_mental_models:
console.print("[blue]Mental models: ONLY (no facts)[/blue]")
elif include_mental_models:
console.print("[blue]Mental models: included in recall[/blue]")
answer_evaluator = LLMAnswerEvaluator()
@@ -705,7 +697,7 @@ async def run_benchmark(
# Configuration for single-phase benchmark
separate_ingestion = False
clear_per_item = True # Use unique agent_id per question
concurrent_questions = 4 if (include_mental_models or only_mental_models) else 8
concurrent_questions = 8
results = await runner.run(
dataset_path=dataset_path,
@@ -726,8 +718,6 @@ async def run_benchmark(
max_concurrent_items=max_concurrent_items, # Parallel instance processing
output_path=output_path, # Save results incrementally
merge_with_existing=merge_with_existing, # Merge when using --fill, --category, --only-failed, --only-invalid flags or specific question
include_mental_models=include_mental_models, # Include mental models in recall results
only_mental_models=only_mental_models, # Only retrieve mental models (no facts)
)
# Display results (final save already happened incrementally)
@@ -978,16 +968,6 @@ if __name__ == "__main__":
default=None,
help="Source results file to read failed/invalid questions from (for --only-failed/--only-invalid). Defaults to benchmark_results.json if not specified.",
)
parser.add_argument(
"--include-mental-models",
action="store_true",
help="Include mental models in recall results. This waits for consolidation to complete after ingestion and includes mental models in the recall response.",
)
parser.add_argument(
"--only-mental-models",
action="store_true",
help="Only retrieve mental models (no facts). This waits for consolidation to complete after ingestion and only returns mental models.",
)
args = parser.parse_args()
@@ -1018,7 +998,5 @@ if __name__ == "__main__":
results_filename=args.results_filename,
context_format=args.context_format,
source_results=args.source_results,
include_mental_models=args.include_mental_models,
only_mental_models=args.only_mental_models,
)
)
+29 -3
View File
@@ -319,7 +319,8 @@ Controls the retain (memory ingestion) pipeline.
|----------|-------------|---------|
| `HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS` | Max completion tokens for fact extraction LLM calls | `64000` |
| `HINDSIGHT_API_RETAIN_CHUNK_SIZE` | Max characters per chunk for fact extraction. Larger chunks extract fewer LLM calls but may lose context. | `3000` |
| `HINDSIGHT_API_RETAIN_EXTRACTION_MODE` | Fact extraction mode: `concise` (selective, fewer high-quality facts) or `verbose` (detailed, more facts) | `concise` |
| `HINDSIGHT_API_RETAIN_EXTRACTION_MODE` | Fact extraction mode: `concise`, `verbose`, or `custom` | `concise` |
| `HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS` | Custom extraction guidelines (only used when mode is `custom`) | - |
| `HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS` | Extract causal relationships between facts | `true` |
#### Extraction Modes
@@ -330,13 +331,38 @@ The extraction mode controls how aggressively facts are extracted from content:
- **`verbose`**: Detailed extraction that captures every piece of information with maximum verbosity. Produces more facts with extensive detail but slower performance and higher token usage.
- **`custom`**: Inject your own extraction guidelines while keeping the structural parts of the prompt (output format, coreference resolution, temporal handling, etc.) intact. Useful for A/B testing different extraction strategies or domain-specific customization.
**Example: Custom Extraction Mode**
```bash
# Set mode to custom
export HINDSIGHT_API_RETAIN_EXTRACTION_MODE=custom
# Define custom guidelines (multi-line is fine)
export HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS="ONLY extract facts that are:
✅ Technical decisions and their rationale
✅ Architecture patterns and design choices
✅ Performance metrics and benchmarks
✅ Code reviews and feedback
DO NOT extract:
❌ Generic greetings or pleasantries
❌ Process chatter (\"let me check\", \"one moment\")
❌ Repeated information already captured
CONSOLIDATE related technical discussions into ONE fact when possible.
Ask yourself: 'Would this technical context be useful in 6 months?' If no, skip it."
```
### Observations (Experimental)
Observations are consolidated knowledge synthesized from facts. This feature is experimental and disabled by default.
Observations are consolidated knowledge synthesized from facts.
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_ENABLE_OBSERVATIONS` | Enable observation consolidation | `false` |
| `HINDSIGHT_API_ENABLE_OBSERVATIONS` | Enable observation consolidation | `true` |
| `HINDSIGHT_API_CONSOLIDATION_BATCH_SIZE` | Memories to load per batch (internal optimization) | `50` |
| `HINDSIGHT_API_RETAIN_OBSERVATIONS_ASYNC` | Run observation generation asynchronously (after retain completes) | `false` |