The 21 the automatic pass could not make on its own. `ruff check` and `ruff format --check` are both clean now; typecheck is still red and is next. `in_reasoning` in chat/service.py was a complete state machine that nothing read: initialised False, set True when a reasoning delta arrived, set False when the summary ended — three assignments, zero reads. Ruff reported one at a time, and removing each revealed the next, so what looked like a single stray variable took three passes to bottom out. The branches themselves do real work and are untouched; only the flag is gone. Four `raise HTTPException` inside `except` blocks now chain with `from e`. Until now a failure while handling an error was indistinguishable from the error, which matters most in exactly the situation where the traceback is all you have. In biographer/tools.py the binding was unused but the call is not: MemoryType() is called for the ValueError it raises on an invalid name. The binding is gone and the call and its comment stay, because dropping the line would have removed the validation. The rest are unused bindings in tests where the assertions are on something else (call_args, mostly), plus three unused loop variables and an isinstance tuple. One correction to my own work: removing a dead comprehension in test_error_handling.py left an `if` block with nothing but comments in it, which is a SyntaxError. Ruff caught it immediately. The block now says what the test actually pins — that the stream parses without crashing, which reaching that line demonstrates — rather than computing a list nobody asserts on. `make test` is intermittent here, and it is not this change. test_tatlock_tool_call_logging_calculator failed in two of five full runs across both HEAD and this branch, and passes in the other three; it also fails in isolation at HEAD while passing in isolation here. Order- or timing-dependent. Recorded rather than chased, since tests are not gated in this repo yet. Co-Authored-By: Claude <noreply@anthropic.com>
463 lines
13 KiB
Python
463 lines
13 KiB
Python
"""
|
|
Biographer tools for PydanticAI agent.
|
|
|
|
These tools enable The Biographer to record and recall the user's story:
|
|
- recall_semantic: Find memories by meaning/concept
|
|
- store_insight: Record new facts about the user
|
|
- list_memories: Browse recorded memories by type
|
|
- forget_memory: Remove specific memories
|
|
|
|
For direct key-based access (get/set profile, preferences),
|
|
use memory_service directly - these tools are for semantic queries.
|
|
"""
|
|
|
|
from src.core.context import get_user
|
|
from src.core.embeddings import get_embedding_client
|
|
from src.core.logging_config import get_logger
|
|
from src.core.memory_service import MemoryType, memory_service
|
|
from src.core.qdrant import get_qdrant_client
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
# ============================================================================
|
|
# Semantic Recall
|
|
# ============================================================================
|
|
|
|
|
|
async def recall_semantic(
|
|
query: str,
|
|
memory_type: str = "",
|
|
limit: int = 5,
|
|
) -> str:
|
|
"""
|
|
Search memories by semantic similarity.
|
|
|
|
Use this to find memories that are conceptually related to
|
|
the query, even if exact words don't match. This is the main
|
|
tool for answering questions like "What car do I drive?" or
|
|
"What did I mention about my job?"
|
|
|
|
Args:
|
|
query: Natural language query to search for
|
|
memory_type: Optional filter: "user_profile", "preference", "learned_fact"
|
|
limit: Maximum memories to return (default: 5)
|
|
|
|
Returns:
|
|
Matching memories with their content and relevance scores
|
|
|
|
Examples:
|
|
recall_semantic("What is my car?")
|
|
recall_semantic("work preferences", memory_type="preference")
|
|
recall_semantic("family members")
|
|
"""
|
|
try:
|
|
user = get_user()
|
|
embedding_client = get_embedding_client()
|
|
qdrant = get_qdrant_client()
|
|
|
|
# Generate embedding for query
|
|
query_vector = await embedding_client.embed(query)
|
|
if not query_vector:
|
|
return "Unable to process query - embedding generation failed"
|
|
|
|
# Search memories
|
|
results = await qdrant.search_memories(
|
|
user=user,
|
|
query_vector=query_vector,
|
|
limit=limit,
|
|
memory_type=memory_type if memory_type else None,
|
|
)
|
|
|
|
if not results:
|
|
return f"No memories found related to '{query}'"
|
|
|
|
output_parts = [f"## Memories matching: {query}\n"]
|
|
|
|
for i, memory in enumerate(results, 1):
|
|
mem_type = memory.get("type", "unknown")
|
|
key = memory.get("key", "")
|
|
value = memory.get("value", "")
|
|
score = memory.get("score", 0.0)
|
|
source = memory.get("source", "unknown")
|
|
|
|
type_icon = {
|
|
"user_profile": "👤",
|
|
"preference": "⚙️",
|
|
"learned_fact": "💡",
|
|
}.get(mem_type, "📝")
|
|
|
|
output_parts.append(f"{i}. {type_icon} **{key}** (relevance: {score:.2f})")
|
|
output_parts.append(f" {value}")
|
|
output_parts.append(f" _Type: {mem_type}, Source: {source}_")
|
|
output_parts.append("")
|
|
|
|
logger.info(
|
|
"memory_recall_semantic",
|
|
query=query[:50],
|
|
result_count=len(results),
|
|
user=user,
|
|
)
|
|
|
|
return "\n".join(output_parts)
|
|
|
|
except Exception as e:
|
|
logger.error("memory_recall_semantic_error", error=str(e), query=query[:50])
|
|
return f"Error searching memories: {str(e)}"
|
|
|
|
|
|
# ============================================================================
|
|
# Store Memory
|
|
# ============================================================================
|
|
|
|
|
|
async def store_insight(
|
|
key: str,
|
|
value: str,
|
|
importance: float = 0.5,
|
|
) -> str:
|
|
"""
|
|
Store a new insight or learned fact about the user.
|
|
|
|
Use this when:
|
|
- User explicitly asks to remember something
|
|
- User shares personal information worth remembering
|
|
- You learn something from conversation that should persist
|
|
|
|
The memory will be stored with vector embedding for semantic search
|
|
and can be recalled later using recall_semantic.
|
|
|
|
Args:
|
|
key: Short identifier for the memory (e.g., "car", "employer", "pet")
|
|
value: The actual information to remember
|
|
importance: How important is this? 0.0 (trivial) to 1.0 (critical)
|
|
|
|
Returns:
|
|
Confirmation of stored memory
|
|
|
|
Examples:
|
|
store_insight("car", "User drives a Tesla Model 3")
|
|
store_insight("employer", "Works at Acme Corp as software engineer", importance=0.8)
|
|
"""
|
|
try:
|
|
# Auto-generate keywords from key and value
|
|
keywords = [key]
|
|
words = value.lower().split()
|
|
keywords.extend([w for w in words if len(w) > 4][:5])
|
|
|
|
success = await memory_service.store_fact(
|
|
key=key,
|
|
value=value,
|
|
keywords=keywords,
|
|
importance=importance,
|
|
source="conversation",
|
|
)
|
|
|
|
if success:
|
|
output_parts = [
|
|
"## Memory Stored",
|
|
f"**Key:** {key}",
|
|
f"**Value:** {value}",
|
|
f"**Keywords:** {', '.join(keywords)}",
|
|
f"**Importance:** {importance:.1f}",
|
|
"",
|
|
"_Memory is now searchable via semantic recall._",
|
|
]
|
|
|
|
logger.info(
|
|
"memory_store_insight",
|
|
key=key,
|
|
importance=importance,
|
|
user=get_user(),
|
|
)
|
|
|
|
return "\n".join(output_parts)
|
|
else:
|
|
return f"Failed to store memory for key '{key}'"
|
|
|
|
except Exception as e:
|
|
logger.error("memory_store_insight_error", error=str(e), key=key)
|
|
return f"Error storing memory: {str(e)}"
|
|
|
|
|
|
async def update_profile(
|
|
key: str,
|
|
value: str,
|
|
) -> str:
|
|
"""
|
|
Update user profile information.
|
|
|
|
Use this for core identity information:
|
|
- name, location, timezone
|
|
- language preferences
|
|
- occupation
|
|
|
|
Profile data has high importance and is used for context
|
|
by the Steward during request analysis.
|
|
|
|
Args:
|
|
key: Profile field (e.g., "name", "location", "timezone")
|
|
value: The value to set
|
|
|
|
Returns:
|
|
Confirmation of profile update
|
|
|
|
Examples:
|
|
update_profile("location", "Amsterdam, Netherlands")
|
|
update_profile("timezone", "Europe/Amsterdam")
|
|
update_profile("name", "John")
|
|
"""
|
|
try:
|
|
success = await memory_service.set_profile(
|
|
key=key,
|
|
value=value,
|
|
keywords=[key, "profile"],
|
|
)
|
|
|
|
if success:
|
|
output_parts = [
|
|
"## Profile Updated",
|
|
f"**{key}:** {value}",
|
|
"",
|
|
"_Profile data is automatically included in context._",
|
|
]
|
|
|
|
logger.info(
|
|
"memory_update_profile",
|
|
key=key,
|
|
user=get_user(),
|
|
)
|
|
|
|
return "\n".join(output_parts)
|
|
else:
|
|
return f"Failed to update profile field '{key}'"
|
|
|
|
except Exception as e:
|
|
logger.error("memory_update_profile_error", error=str(e), key=key)
|
|
return f"Error updating profile: {str(e)}"
|
|
|
|
|
|
async def update_preference(
|
|
key: str,
|
|
value: str,
|
|
) -> str:
|
|
"""
|
|
Update user preferences.
|
|
|
|
Use this for settings and preferences:
|
|
- temperature_unit (celsius/fahrenheit)
|
|
- distance_unit (metric/imperial)
|
|
- theme, language, etc.
|
|
|
|
Preferences are used by agents to customize responses.
|
|
|
|
Args:
|
|
key: Preference name (e.g., "temperature_unit", "theme")
|
|
value: Preference value
|
|
|
|
Returns:
|
|
Confirmation of preference update
|
|
|
|
Examples:
|
|
update_preference("temperature_unit", "celsius")
|
|
update_preference("distance_unit", "metric")
|
|
update_preference("theme", "dark")
|
|
"""
|
|
try:
|
|
success = await memory_service.set_preference(
|
|
key=key,
|
|
value=value,
|
|
)
|
|
|
|
if success:
|
|
output_parts = [
|
|
"## Preference Updated",
|
|
f"**{key}:** {value}",
|
|
"",
|
|
"_Preference will be applied to future responses._",
|
|
]
|
|
|
|
logger.info(
|
|
"memory_update_preference",
|
|
key=key,
|
|
user=get_user(),
|
|
)
|
|
|
|
return "\n".join(output_parts)
|
|
else:
|
|
return f"Failed to update preference '{key}'"
|
|
|
|
except Exception as e:
|
|
logger.error("memory_update_preference_error", error=str(e), key=key)
|
|
return f"Error updating preference: {str(e)}"
|
|
|
|
|
|
# ============================================================================
|
|
# List Memories
|
|
# ============================================================================
|
|
|
|
|
|
async def list_memories(
|
|
memory_type: str = "learned_fact",
|
|
limit: int = 20,
|
|
) -> str:
|
|
"""
|
|
List stored memories of a specific type.
|
|
|
|
Use this to browse what's stored in memory without
|
|
a specific search query.
|
|
|
|
Args:
|
|
memory_type: Type to list: "user_profile", "preference", "learned_fact"
|
|
limit: Maximum memories to return (default: 20)
|
|
|
|
Returns:
|
|
List of memories with their keys and values
|
|
|
|
Examples:
|
|
list_memories("user_profile")
|
|
list_memories("preference")
|
|
list_memories("learned_fact", limit=10)
|
|
"""
|
|
try:
|
|
user = get_user()
|
|
qdrant = get_qdrant_client()
|
|
|
|
# Convert string to MemoryType
|
|
try:
|
|
MemoryType(memory_type) # validated for its ValueError; the value is unused
|
|
except ValueError:
|
|
return f"Invalid memory type '{memory_type}'. Use: user_profile, preference, or learned_fact"
|
|
|
|
# Get all memories of type
|
|
results = qdrant._client.scroll(
|
|
collection_name=f"memories_{user}",
|
|
scroll_filter={
|
|
"must": [
|
|
{"key": "type", "match": {"value": memory_type}},
|
|
]
|
|
},
|
|
limit=limit,
|
|
with_payload=True,
|
|
with_vectors=False,
|
|
)
|
|
|
|
points, _ = results
|
|
if not points:
|
|
return f"No {memory_type} memories found"
|
|
|
|
type_icon = {
|
|
"user_profile": "👤",
|
|
"preference": "⚙️",
|
|
"learned_fact": "💡",
|
|
}.get(memory_type, "📝")
|
|
|
|
output_parts = [f"## {type_icon} {memory_type.replace('_', ' ').title()} Memories\n"]
|
|
|
|
for point in points:
|
|
payload = point.payload
|
|
key = payload.get("key", "unknown")
|
|
value = payload.get("value", "")
|
|
importance = payload.get("importance", 0.5)
|
|
|
|
output_parts.append(f"- **{key}**: {value}")
|
|
if importance > 0.7:
|
|
output_parts.append(f" _(importance: {importance:.1f})_")
|
|
|
|
logger.info(
|
|
"memory_list",
|
|
memory_type=memory_type,
|
|
count=len(points),
|
|
user=user,
|
|
)
|
|
|
|
return "\n".join(output_parts)
|
|
|
|
except Exception as e:
|
|
logger.error("memory_list_error", error=str(e), memory_type=memory_type)
|
|
return f"Error listing memories: {str(e)}"
|
|
|
|
|
|
# ============================================================================
|
|
# Forget Memory
|
|
# ============================================================================
|
|
|
|
|
|
async def forget_memory(
|
|
key: str,
|
|
memory_type: str = "learned_fact",
|
|
) -> str:
|
|
"""
|
|
Remove a specific memory.
|
|
|
|
Use this when:
|
|
- User asks to forget something
|
|
- Information is outdated or incorrect
|
|
- Privacy concerns
|
|
|
|
Args:
|
|
key: Key of the memory to forget
|
|
memory_type: Type of memory: "user_profile", "preference", "learned_fact"
|
|
|
|
Returns:
|
|
Confirmation of deletion
|
|
|
|
Examples:
|
|
forget_memory("old_car")
|
|
forget_memory("location", memory_type="user_profile")
|
|
forget_memory("theme", memory_type="preference")
|
|
"""
|
|
try:
|
|
# Convert string to MemoryType
|
|
try:
|
|
mem_type = MemoryType(memory_type)
|
|
except ValueError:
|
|
return f"Invalid memory type '{memory_type}'. Use: user_profile, preference, or learned_fact"
|
|
|
|
success = await memory_service.delete_memory(
|
|
key=key,
|
|
memory_type=mem_type,
|
|
)
|
|
|
|
if success:
|
|
output_parts = [
|
|
"## Memory Forgotten",
|
|
f"**Key:** {key}",
|
|
f"**Type:** {memory_type}",
|
|
"",
|
|
"_Memory has been removed._",
|
|
]
|
|
|
|
logger.info(
|
|
"memory_forget",
|
|
key=key,
|
|
memory_type=memory_type,
|
|
user=get_user(),
|
|
)
|
|
|
|
return "\n".join(output_parts)
|
|
else:
|
|
return f"Memory '{key}' not found or already deleted"
|
|
|
|
except Exception as e:
|
|
logger.error("memory_forget_error", error=str(e), key=key)
|
|
return f"Error forgetting memory: {str(e)}"
|
|
|
|
|
|
# ============================================================================
|
|
# Tool Collection for Registration
|
|
# ============================================================================
|
|
|
|
# All tools available to The Biographer
|
|
BIOGRAPHER_TOOLS = [
|
|
# Recall
|
|
recall_semantic,
|
|
list_memories,
|
|
# Record
|
|
store_insight,
|
|
update_profile,
|
|
update_preference,
|
|
# Manage
|
|
forget_memory,
|
|
]
|