* chore: run benchmarks with reflect mode * chore: run benchmarks with reflect mode * fixes * new mm * bunch of fixes * initial commit * fixes * fixes * fixes * fix: sometimes memories gets extracted in the wrong language
438 lines
14 KiB
Python
438 lines
14 KiB
Python
"""
|
|
Tool implementations for the reflect agent.
|
|
|
|
Implements hierarchical retrieval:
|
|
1. search_reflections - User-curated summaries (highest quality)
|
|
2. search_mental_models - Consolidated knowledge with freshness
|
|
3. recall - Raw facts as ground truth
|
|
"""
|
|
|
|
import logging
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
if TYPE_CHECKING:
|
|
from asyncpg import Connection
|
|
|
|
from ...api.http import RequestContext
|
|
from ..memory_engine import MemoryEngine
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Mental model is considered stale if not updated in this many days
|
|
STALE_THRESHOLD_DAYS = 7
|
|
|
|
|
|
async def tool_search_reflections(
|
|
conn: "Connection",
|
|
bank_id: str,
|
|
query: str,
|
|
query_embedding: list[float],
|
|
max_results: int = 5,
|
|
tags: list[str] | None = None,
|
|
tags_match: str = "any",
|
|
exclude_ids: list[str] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Search user-curated reflections by semantic similarity.
|
|
|
|
Reflections are high-quality, manually created summaries about specific topics.
|
|
They should be searched FIRST as they represent the most reliable synthesized knowledge.
|
|
|
|
Args:
|
|
conn: Database connection
|
|
bank_id: Bank identifier
|
|
query: Search query (for logging/tracing)
|
|
query_embedding: Pre-computed embedding for semantic search
|
|
max_results: Maximum number of reflections to return
|
|
tags: Optional tags to filter reflections
|
|
tags_match: How to match tags - "any" (OR), "all" (AND)
|
|
exclude_ids: Optional list of reflection IDs to exclude (e.g., when refreshing a reflection)
|
|
|
|
Returns:
|
|
Dict with matching reflections including content and freshness info
|
|
"""
|
|
from ..memory_engine import fq_table
|
|
|
|
# Build filters dynamically
|
|
filters = ""
|
|
params: list[Any] = [bank_id, str(query_embedding), max_results]
|
|
next_param = 4
|
|
|
|
if tags:
|
|
if tags_match == "all":
|
|
filters += f" AND tags @> ${next_param}::varchar[]"
|
|
else:
|
|
filters += f" AND (tags && ${next_param}::varchar[] OR tags IS NULL OR tags = '{{}}')"
|
|
params.append(tags)
|
|
next_param += 1
|
|
|
|
if exclude_ids:
|
|
filters += f" AND id != ALL(${next_param}::uuid[])"
|
|
params.append(exclude_ids)
|
|
next_param += 1
|
|
|
|
# Search reflections by embedding similarity
|
|
rows = await conn.fetch(
|
|
f"""
|
|
SELECT
|
|
id, name, content, reflect_response,
|
|
tags, created_at, last_refreshed_at,
|
|
1 - (embedding <=> $2::vector) as relevance
|
|
FROM {fq_table("reflections")}
|
|
WHERE bank_id = $1 AND embedding IS NOT NULL {filters}
|
|
ORDER BY embedding <=> $2::vector
|
|
LIMIT $3
|
|
""",
|
|
*params,
|
|
)
|
|
|
|
now = datetime.now(timezone.utc)
|
|
reflections = []
|
|
|
|
for row in rows:
|
|
last_refreshed_at = row["last_refreshed_at"]
|
|
if last_refreshed_at and last_refreshed_at.tzinfo is None:
|
|
last_refreshed_at = last_refreshed_at.replace(tzinfo=timezone.utc)
|
|
|
|
# Calculate freshness
|
|
is_stale = False
|
|
if last_refreshed_at:
|
|
age = now - last_refreshed_at
|
|
is_stale = age > timedelta(days=STALE_THRESHOLD_DAYS)
|
|
|
|
reflections.append(
|
|
{
|
|
"id": str(row["id"]),
|
|
"name": row["name"],
|
|
"content": row["content"],
|
|
"reflect_response": row["reflect_response"],
|
|
"tags": row["tags"] or [],
|
|
"relevance": round(row["relevance"], 4),
|
|
"updated_at": last_refreshed_at.isoformat() if last_refreshed_at else None,
|
|
"is_stale": is_stale,
|
|
}
|
|
)
|
|
|
|
return {
|
|
"query": query,
|
|
"count": len(reflections),
|
|
"reflections": reflections,
|
|
}
|
|
|
|
|
|
async def tool_search_mental_models(
|
|
memory_engine: "MemoryEngine",
|
|
bank_id: str,
|
|
query: str,
|
|
request_context: "RequestContext",
|
|
max_tokens: int = 5000,
|
|
tags: list[str] | None = None,
|
|
tags_match: str = "any",
|
|
last_consolidated_at: datetime | None = None,
|
|
pending_consolidation: int = 0,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Search consolidated mental models using recall with include_mental_models.
|
|
|
|
Mental models are auto-generated from memories. Returns freshness info
|
|
so the agent knows if it should also verify with recall().
|
|
|
|
Args:
|
|
memory_engine: Memory engine instance
|
|
bank_id: Bank identifier
|
|
query: Search query
|
|
request_context: Request context for authentication
|
|
max_tokens: Maximum tokens for results (default 5000)
|
|
tags: Optional tags to filter models
|
|
tags_match: How to match tags - "any" (OR), "all" (AND)
|
|
last_consolidated_at: When consolidation last ran (for staleness check)
|
|
pending_consolidation: Number of memories waiting to be consolidated
|
|
|
|
Returns:
|
|
Dict with matching mental models including freshness info
|
|
"""
|
|
from ..memory_engine import fq_table
|
|
|
|
# Use recall to search mental models (they come back in results field when fact_type=["mental_model"])
|
|
result = await memory_engine.recall_async(
|
|
bank_id=bank_id,
|
|
query=query,
|
|
fact_type=["mental_model"], # Only retrieve mental models
|
|
max_tokens=max_tokens, # Token budget controls how many mental models are returned
|
|
enable_trace=False,
|
|
request_context=request_context,
|
|
tags=tags,
|
|
tags_match=tags_match,
|
|
_connection_budget=1,
|
|
_quiet=True,
|
|
)
|
|
|
|
mental_models = []
|
|
|
|
# When fact_type=["mental_model"], results come back in `results` field as MemoryFact objects
|
|
# We need to fetch additional fields (proof_count, source_memory_ids) from the database
|
|
if result.results:
|
|
mm_ids = [m.id for m in result.results]
|
|
|
|
# Fetch proof_count and source_memory_ids for these mental models
|
|
pool = await memory_engine._get_pool()
|
|
async with pool.acquire() as conn:
|
|
mm_rows = await conn.fetch(
|
|
f"""
|
|
SELECT id, proof_count, source_memory_ids
|
|
FROM {fq_table("memory_units")}
|
|
WHERE id = ANY($1::uuid[])
|
|
""",
|
|
mm_ids,
|
|
)
|
|
mm_data = {str(row["id"]): row for row in mm_rows}
|
|
|
|
for m in result.results:
|
|
# Get additional data from DB lookup
|
|
extra = mm_data.get(m.id, {})
|
|
proof_count = extra.get("proof_count", 1) if extra else 1
|
|
source_ids = extra.get("source_memory_ids", []) if extra else []
|
|
# Convert UUIDs to strings
|
|
source_memory_ids = [str(sid) for sid in (source_ids or [])]
|
|
|
|
# Determine staleness
|
|
is_stale = False
|
|
staleness_reason = None
|
|
if pending_consolidation > 0:
|
|
is_stale = True
|
|
staleness_reason = f"{pending_consolidation} memories pending consolidation"
|
|
|
|
mental_models.append(
|
|
{
|
|
"id": str(m.id),
|
|
"text": m.text,
|
|
"proof_count": proof_count,
|
|
"source_memory_ids": source_memory_ids,
|
|
"tags": m.tags or [],
|
|
"is_stale": is_stale,
|
|
"staleness_reason": staleness_reason,
|
|
}
|
|
)
|
|
|
|
# Return freshness info (more understandable than raw pending_consolidation count)
|
|
if pending_consolidation == 0:
|
|
freshness = "up_to_date"
|
|
elif pending_consolidation < 10:
|
|
freshness = "slightly_stale"
|
|
else:
|
|
freshness = "stale"
|
|
|
|
return {
|
|
"query": query,
|
|
"count": len(mental_models),
|
|
"mental_models": mental_models,
|
|
"freshness": freshness,
|
|
}
|
|
|
|
|
|
async def tool_recall(
|
|
memory_engine: "MemoryEngine",
|
|
bank_id: str,
|
|
query: str,
|
|
request_context: "RequestContext",
|
|
max_tokens: int = 2048,
|
|
max_results: int = 50,
|
|
tags: list[str] | None = None,
|
|
tags_match: str = "any",
|
|
connection_budget: int = 1,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Search memories using TEMPR retrieval.
|
|
|
|
This is the ground truth - raw facts and experiences.
|
|
Use when reflections/mental models don't exist, are stale, or need verification.
|
|
|
|
Args:
|
|
memory_engine: Memory engine instance
|
|
bank_id: Bank identifier
|
|
query: Search query
|
|
request_context: Request context for authentication
|
|
max_tokens: Maximum tokens for results (default 2048)
|
|
max_results: Maximum number of results
|
|
tags: Filter by tags (includes untagged memories)
|
|
tags_match: How to match tags - "any" (OR), "all" (AND), or "exact"
|
|
connection_budget: Max DB connections for this recall (default 1 for internal ops)
|
|
|
|
Returns:
|
|
Dict with list of matching memories
|
|
"""
|
|
result = await memory_engine.recall_async(
|
|
bank_id=bank_id,
|
|
query=query,
|
|
fact_type=["experience", "world"], # Exclude opinions and mental_models
|
|
max_tokens=max_tokens,
|
|
enable_trace=False,
|
|
request_context=request_context,
|
|
tags=tags,
|
|
tags_match=tags_match,
|
|
_connection_budget=connection_budget,
|
|
_quiet=True, # Suppress logging for internal operations
|
|
)
|
|
|
|
memories = []
|
|
for m in result.results[:max_results]:
|
|
memories.append(
|
|
{
|
|
"id": str(m.id),
|
|
"text": m.text,
|
|
"type": m.fact_type,
|
|
"entities": m.entities or [],
|
|
"occurred": m.occurred_start, # Already ISO format string
|
|
}
|
|
)
|
|
|
|
return {
|
|
"query": query,
|
|
"count": len(memories),
|
|
"memories": memories,
|
|
}
|
|
|
|
|
|
async def tool_expand(
|
|
conn: "Connection",
|
|
bank_id: str,
|
|
memory_ids: list[str],
|
|
depth: str,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Expand multiple memories to get chunk or document context.
|
|
|
|
Args:
|
|
conn: Database connection
|
|
bank_id: Bank identifier
|
|
memory_ids: List of memory unit IDs
|
|
depth: "chunk" or "document"
|
|
|
|
Returns:
|
|
Dict with results array, each containing memory, chunk, and optionally document data
|
|
"""
|
|
from ..memory_engine import fq_table
|
|
|
|
if not memory_ids:
|
|
return {"error": "memory_ids is required and must not be empty"}
|
|
|
|
# Validate and convert UUIDs
|
|
valid_uuids: list[uuid.UUID] = []
|
|
errors: dict[str, str] = {}
|
|
for mid in memory_ids:
|
|
try:
|
|
valid_uuids.append(uuid.UUID(mid))
|
|
except ValueError:
|
|
errors[mid] = f"Invalid memory_id format: {mid}"
|
|
|
|
if not valid_uuids:
|
|
return {"error": "No valid memory IDs provided", "details": errors}
|
|
|
|
# Batch fetch all memory units
|
|
memories = await conn.fetch(
|
|
f"""
|
|
SELECT id, text, chunk_id, document_id, fact_type, context
|
|
FROM {fq_table("memory_units")}
|
|
WHERE id = ANY($1) AND bank_id = $2
|
|
""",
|
|
valid_uuids,
|
|
bank_id,
|
|
)
|
|
memory_map = {row["id"]: row for row in memories}
|
|
|
|
# Collect chunk_ids and document_ids for batch fetching
|
|
chunk_ids = [m["chunk_id"] for m in memories if m["chunk_id"]]
|
|
doc_ids_from_chunks: set[str] = set()
|
|
doc_ids_direct: set[str] = set()
|
|
|
|
# Batch fetch all chunks
|
|
chunk_map: dict[str, Any] = {}
|
|
if chunk_ids:
|
|
chunks = await conn.fetch(
|
|
f"""
|
|
SELECT chunk_id, chunk_text, chunk_index, document_id
|
|
FROM {fq_table("chunks")}
|
|
WHERE chunk_id = ANY($1)
|
|
""",
|
|
chunk_ids,
|
|
)
|
|
chunk_map = {row["chunk_id"]: row for row in chunks}
|
|
if depth == "document":
|
|
doc_ids_from_chunks = {c["document_id"] for c in chunks if c["document_id"]}
|
|
|
|
# Collect direct document IDs (memories without chunks)
|
|
if depth == "document":
|
|
for m in memories:
|
|
if not m["chunk_id"] and m["document_id"]:
|
|
doc_ids_direct.add(m["document_id"])
|
|
|
|
# Batch fetch all documents
|
|
doc_map: dict[str, Any] = {}
|
|
all_doc_ids = list(doc_ids_from_chunks | doc_ids_direct)
|
|
if all_doc_ids:
|
|
docs = await conn.fetch(
|
|
f"""
|
|
SELECT id, original_text, metadata, retain_params
|
|
FROM {fq_table("documents")}
|
|
WHERE id = ANY($1) AND bank_id = $2
|
|
""",
|
|
all_doc_ids,
|
|
bank_id,
|
|
)
|
|
doc_map = {row["id"]: row for row in docs}
|
|
|
|
# Build results
|
|
results: list[dict[str, Any]] = []
|
|
for mid, mem_uuid in zip(memory_ids, valid_uuids):
|
|
if mid in errors:
|
|
results.append({"memory_id": mid, "error": errors[mid]})
|
|
continue
|
|
|
|
memory = memory_map.get(mem_uuid)
|
|
if not memory:
|
|
results.append({"memory_id": mid, "error": f"Memory not found: {mid}"})
|
|
continue
|
|
|
|
item: dict[str, Any] = {
|
|
"memory_id": mid,
|
|
"memory": {
|
|
"id": str(memory["id"]),
|
|
"text": memory["text"],
|
|
"type": memory["fact_type"],
|
|
"context": memory["context"],
|
|
},
|
|
}
|
|
|
|
# Add chunk if available
|
|
if memory["chunk_id"] and memory["chunk_id"] in chunk_map:
|
|
chunk = chunk_map[memory["chunk_id"]]
|
|
item["chunk"] = {
|
|
"id": chunk["chunk_id"],
|
|
"text": chunk["chunk_text"],
|
|
"index": chunk["chunk_index"],
|
|
"document_id": chunk["document_id"],
|
|
}
|
|
# Add document if depth=document
|
|
if depth == "document" and chunk["document_id"] in doc_map:
|
|
doc = doc_map[chunk["document_id"]]
|
|
item["document"] = {
|
|
"id": doc["id"],
|
|
"full_text": doc["original_text"],
|
|
"metadata": doc["metadata"],
|
|
"retain_params": doc["retain_params"],
|
|
}
|
|
elif memory["document_id"] and depth == "document" and memory["document_id"] in doc_map:
|
|
# No chunk, but has document_id
|
|
doc = doc_map[memory["document_id"]]
|
|
item["document"] = {
|
|
"id": doc["id"],
|
|
"full_text": doc["original_text"],
|
|
"metadata": doc["metadata"],
|
|
"retain_params": doc["retain_params"],
|
|
}
|
|
|
|
results.append(item)
|
|
|
|
return {"results": results, "count": len(results)}
|