1811 lines
66 KiB
Python
1811 lines
66 KiB
Python
"""
|
|
FastAPI application factory and API routes for memory system.
|
|
|
|
This module provides the create_app function to create and configure
|
|
the FastAPI application with all API endpoints.
|
|
"""
|
|
import json
|
|
import logging
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Optional, List, Dict, Any, Union
|
|
from datetime import datetime
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI, HTTPException, Query
|
|
|
|
|
|
def _parse_metadata(metadata: Any) -> Dict[str, Any]:
|
|
"""Parse metadata that may be a dict, JSON string, or None."""
|
|
if metadata is None:
|
|
return {}
|
|
if isinstance(metadata, dict):
|
|
return metadata
|
|
if isinstance(metadata, str):
|
|
try:
|
|
return json.loads(metadata)
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
return {}
|
|
|
|
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.responses import FileResponse
|
|
from pydantic import BaseModel, Field, ConfigDict
|
|
|
|
from hindsight_api import MemoryEngine
|
|
from hindsight_api.engine.db_utils import acquire_with_retry
|
|
|
|
|
|
class MetadataFilter(BaseModel):
|
|
"""Filter for metadata fields. Matches records where (key=value) OR (key not set) when match_unset=True."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"key": "source",
|
|
"value": "slack",
|
|
"match_unset": True
|
|
}
|
|
})
|
|
|
|
key: str = Field(description="Metadata key to filter on")
|
|
value: Optional[str] = Field(default=None, description="Value to match. If None with match_unset=True, matches any record where key is not set.")
|
|
match_unset: bool = Field(default=True, description="If True, also match records where this metadata key is not set")
|
|
|
|
|
|
class SearchRequest(BaseModel):
|
|
"""Request model for search endpoint."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"query": "What did Alice say about machine learning?",
|
|
"fact_type": ["world", "agent"],
|
|
"thinking_budget": 100,
|
|
"max_tokens": 4096,
|
|
"trace": True,
|
|
"question_date": "2023-05-30T23:40:00",
|
|
"metadata_filter": [{"key": "source", "value": "slack", "match_unset": True}],
|
|
"include_entities": True,
|
|
"max_entity_tokens": 500
|
|
}
|
|
})
|
|
|
|
query: str
|
|
fact_type: Optional[List[str]] = None # List of fact types to search (defaults to all if not specified)
|
|
thinking_budget: int = 100
|
|
max_tokens: int = 4096
|
|
trace: bool = False
|
|
question_date: Optional[str] = None # ISO format date string (e.g., "2023-05-30T23:40:00")
|
|
metadata_filter: Optional[List[MetadataFilter]] = Field(default=None, description="Filter by metadata. Multiple filters are ANDed together.")
|
|
include_entities: bool = Field(default=False, description="Whether to include entity observations in the response")
|
|
max_entity_tokens: int = Field(default=500, description="Maximum tokens for entity observations")
|
|
|
|
|
|
class SearchResult(BaseModel):
|
|
"""Single search result item."""
|
|
model_config = {
|
|
"populate_by_name": True,
|
|
"json_schema_extra": {
|
|
"example": {
|
|
"id": "123e4567-e89b-12d3-a456-426614174000",
|
|
"text": "Alice works at Google on the AI team",
|
|
"type": "world",
|
|
"entities": ["Alice", "Google"],
|
|
"context": "work info",
|
|
"occurred_start": "2024-01-15T10:30:00Z",
|
|
"occurred_end": "2024-01-15T10:30:00Z",
|
|
"mentioned_at": "2024-01-15T10:30:00Z",
|
|
"document_id": "session_abc123",
|
|
"metadata": {"source": "slack"}
|
|
}
|
|
}
|
|
}
|
|
|
|
id: str
|
|
text: str
|
|
type: Optional[str] = None # fact type: world, agent, opinion, observation
|
|
entities: Optional[List[str]] = None # Entity names mentioned in this fact
|
|
context: Optional[str] = None
|
|
occurred_start: Optional[str] = None # ISO format date when the event started
|
|
occurred_end: Optional[str] = None # ISO format date when the event ended
|
|
mentioned_at: Optional[str] = None # ISO format date when the fact was mentioned
|
|
document_id: Optional[str] = None # Document this memory belongs to
|
|
metadata: Optional[Dict[str, str]] = None # User-defined metadata
|
|
|
|
|
|
class EntityObservationResponse(BaseModel):
|
|
"""An observation about an entity."""
|
|
text: str
|
|
mentioned_at: Optional[str] = None
|
|
|
|
|
|
class EntityStateResponse(BaseModel):
|
|
"""Current mental model of an entity."""
|
|
entity_id: str
|
|
canonical_name: str
|
|
observations: List[EntityObservationResponse]
|
|
|
|
|
|
class EntityListItem(BaseModel):
|
|
"""Entity list item with summary."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"id": "123e4567-e89b-12d3-a456-426614174000",
|
|
"canonical_name": "John",
|
|
"mention_count": 15,
|
|
"first_seen": "2024-01-15T10:30:00Z",
|
|
"last_seen": "2024-02-01T14:00:00Z"
|
|
}
|
|
})
|
|
|
|
id: str
|
|
canonical_name: str
|
|
mention_count: int
|
|
first_seen: Optional[str] = None
|
|
last_seen: Optional[str] = None
|
|
metadata: Optional[Dict[str, Any]] = None
|
|
|
|
|
|
class EntityListResponse(BaseModel):
|
|
"""Response model for entity list endpoint."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"entities": [
|
|
{
|
|
"id": "123e4567-e89b-12d3-a456-426614174000",
|
|
"canonical_name": "John",
|
|
"mention_count": 15,
|
|
"first_seen": "2024-01-15T10:30:00Z",
|
|
"last_seen": "2024-02-01T14:00:00Z"
|
|
}
|
|
]
|
|
}
|
|
})
|
|
|
|
entities: List[EntityListItem]
|
|
|
|
|
|
class EntityDetailResponse(BaseModel):
|
|
"""Response model for entity detail endpoint."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"id": "123e4567-e89b-12d3-a456-426614174000",
|
|
"canonical_name": "John",
|
|
"mention_count": 15,
|
|
"first_seen": "2024-01-15T10:30:00Z",
|
|
"last_seen": "2024-02-01T14:00:00Z",
|
|
"observations": [
|
|
{"text": "John works at Google", "mentioned_at": "2024-01-15T10:30:00Z"}
|
|
]
|
|
}
|
|
})
|
|
|
|
id: str
|
|
canonical_name: str
|
|
mention_count: int
|
|
first_seen: Optional[str] = None
|
|
last_seen: Optional[str] = None
|
|
metadata: Optional[Dict[str, Any]] = None
|
|
observations: List[EntityObservationResponse]
|
|
|
|
|
|
class SearchResponse(BaseModel):
|
|
"""Response model for search endpoints."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"results": [
|
|
{
|
|
"id": "123e4567-e89b-12d3-a456-426614174000",
|
|
"text": "Alice works at Google on the AI team",
|
|
"type": "world",
|
|
"entities": ["Alice", "Google"],
|
|
"context": "work info",
|
|
"occurred_start": "2024-01-15T10:30:00Z",
|
|
"occurred_end": "2024-01-15T10:30:00Z"
|
|
}
|
|
],
|
|
"trace": {
|
|
"query": "What did Alice say about machine learning?",
|
|
"num_results": 1,
|
|
"time_seconds": 0.123
|
|
},
|
|
"entities": {
|
|
"Alice": {
|
|
"entity_id": "123e4567-e89b-12d3-a456-426614174001",
|
|
"canonical_name": "Alice",
|
|
"observations": [
|
|
{"text": "Alice works at Google on the AI team", "mentioned_at": "2024-01-15T10:30:00Z"}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
results: List[SearchResult]
|
|
trace: Optional[Dict[str, Any]] = None
|
|
entities: Optional[Dict[str, EntityStateResponse]] = Field(default=None, description="Entity states for entities mentioned in results")
|
|
|
|
|
|
class MemoryItem(BaseModel):
|
|
"""Single memory item for batch put."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"content": "Alice mentioned she's working on a new ML model",
|
|
"event_date": "2024-01-15T10:30:00Z",
|
|
"context": "team meeting",
|
|
"metadata": {"source": "slack", "channel": "engineering"}
|
|
}
|
|
})
|
|
|
|
content: str
|
|
event_date: Optional[datetime] = None
|
|
context: Optional[str] = None
|
|
metadata: Optional[Dict[str, str]] = None
|
|
|
|
|
|
class BatchPutRequest(BaseModel):
|
|
"""Request model for batch put endpoint."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"items": [
|
|
{
|
|
"content": "Alice works at Google",
|
|
"context": "work"
|
|
},
|
|
{
|
|
"content": "Bob went hiking yesterday",
|
|
"event_date": "2024-01-15T10:00:00Z"
|
|
}
|
|
],
|
|
"document_id": "conversation_123"
|
|
}
|
|
})
|
|
|
|
items: List[MemoryItem]
|
|
document_id: Optional[str] = None
|
|
|
|
|
|
class BatchPutResponse(BaseModel):
|
|
"""Response model for batch put endpoint."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"success": True,
|
|
"message": "Successfully stored 2 memory items",
|
|
"agent_id": "user123",
|
|
"document_id": "conversation_123",
|
|
"items_count": 2
|
|
}
|
|
})
|
|
|
|
success: bool
|
|
message: str
|
|
agent_id: str
|
|
document_id: Optional[str] = None
|
|
items_count: int
|
|
|
|
|
|
class BatchPutAsyncResponse(BaseModel):
|
|
"""Response model for async batch put endpoint."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"success": True,
|
|
"message": "Batch put task queued for background processing",
|
|
"agent_id": "user123",
|
|
"document_id": "conversation_123",
|
|
"items_count": 2,
|
|
"queued": True
|
|
}
|
|
})
|
|
|
|
success: bool
|
|
message: str
|
|
agent_id: str
|
|
document_id: Optional[str] = None
|
|
items_count: int
|
|
queued: bool
|
|
|
|
|
|
class ThinkRequest(BaseModel):
|
|
"""Request model for think endpoint."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"query": "What do you think about artificial intelligence?",
|
|
"thinking_budget": 50,
|
|
"context": "This is for a research paper on AI ethics",
|
|
"metadata_filter": [{"key": "source", "value": "slack", "match_unset": True}]
|
|
}
|
|
})
|
|
|
|
query: str
|
|
thinking_budget: int = 50
|
|
context: Optional[str] = None
|
|
metadata_filter: Optional[List[MetadataFilter]] = Field(default=None, description="Filter by metadata. Multiple filters are ANDed together.")
|
|
|
|
|
|
class OpinionItem(BaseModel):
|
|
"""Model for an opinion with confidence score."""
|
|
text: str
|
|
confidence: float
|
|
|
|
|
|
class ThinkFact(BaseModel):
|
|
"""A fact used in think response."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"id": "123e4567-e89b-12d3-a456-426614174000",
|
|
"text": "AI is used in healthcare",
|
|
"type": "world",
|
|
"context": "healthcare discussion",
|
|
"occurred_start": "2024-01-15T10:30:00Z",
|
|
"occurred_end": "2024-01-15T10:30:00Z"
|
|
}
|
|
})
|
|
|
|
id: Optional[str] = None
|
|
text: str
|
|
type: Optional[str] = None # fact type: world, agent, opinion
|
|
context: Optional[str] = None
|
|
occurred_start: Optional[str] = None
|
|
occurred_end: Optional[str] = None
|
|
|
|
|
|
class ThinkResponse(BaseModel):
|
|
"""Response model for think endpoint."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"text": "Based on my understanding, AI is a transformative technology...",
|
|
"based_on": [
|
|
{
|
|
"id": "123",
|
|
"text": "AI is used in healthcare",
|
|
"type": "world"
|
|
},
|
|
{
|
|
"id": "456",
|
|
"text": "I discussed AI applications last week",
|
|
"type": "agent"
|
|
}
|
|
],
|
|
"new_opinions": [
|
|
"AI has great potential when used responsibly"
|
|
]
|
|
}
|
|
})
|
|
|
|
text: str
|
|
based_on: List[ThinkFact] = [] # Facts used to generate the response
|
|
new_opinions: List[str] = [] # Simplified to list of opinion strings
|
|
|
|
|
|
class AgentsResponse(BaseModel):
|
|
"""Response model for agents list endpoint."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"agents": ["user123", "agent_alice", "agent_bob"]
|
|
}
|
|
})
|
|
|
|
agents: List[str]
|
|
|
|
|
|
class PersonalityTraits(BaseModel):
|
|
"""Personality traits based on Big Five model."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"openness": 0.8,
|
|
"conscientiousness": 0.6,
|
|
"extraversion": 0.5,
|
|
"agreeableness": 0.7,
|
|
"neuroticism": 0.3,
|
|
"bias_strength": 0.7
|
|
}
|
|
})
|
|
|
|
openness: float = Field(ge=0.0, le=1.0, description="Openness to experience (0-1)")
|
|
conscientiousness: float = Field(ge=0.0, le=1.0, description="Conscientiousness (0-1)")
|
|
extraversion: float = Field(ge=0.0, le=1.0, description="Extraversion (0-1)")
|
|
agreeableness: float = Field(ge=0.0, le=1.0, description="Agreeableness (0-1)")
|
|
neuroticism: float = Field(ge=0.0, le=1.0, description="Neuroticism (0-1)")
|
|
bias_strength: float = Field(ge=0.0, le=1.0, description="How strongly personality influences opinions (0-1)")
|
|
|
|
|
|
class AgentProfileResponse(BaseModel):
|
|
"""Response model for agent profile."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"agent_id": "user123",
|
|
"name": "Alice",
|
|
"personality": {
|
|
"openness": 0.8,
|
|
"conscientiousness": 0.6,
|
|
"extraversion": 0.5,
|
|
"agreeableness": 0.7,
|
|
"neuroticism": 0.3,
|
|
"bias_strength": 0.7
|
|
},
|
|
"background": "I am a software engineer with 10 years of experience in startups"
|
|
}
|
|
})
|
|
|
|
agent_id: str
|
|
name: str
|
|
personality: PersonalityTraits
|
|
background: str
|
|
|
|
|
|
class UpdatePersonalityRequest(BaseModel):
|
|
"""Request model for updating personality traits."""
|
|
personality: PersonalityTraits
|
|
|
|
|
|
class AddBackgroundRequest(BaseModel):
|
|
"""Request model for adding/merging background information."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"content": "I was born in Texas",
|
|
"update_personality": True
|
|
}
|
|
})
|
|
|
|
content: str = Field(description="New background information to add or merge")
|
|
update_personality: bool = Field(
|
|
default=True,
|
|
description="If true, infer Big Five personality traits from the merged background (default: true)"
|
|
)
|
|
|
|
|
|
class BackgroundResponse(BaseModel):
|
|
"""Response model for background update."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"background": "I was born in Texas. I am a software engineer with 10 years of experience.",
|
|
"personality": {
|
|
"openness": 0.7,
|
|
"conscientiousness": 0.6,
|
|
"extraversion": 0.5,
|
|
"agreeableness": 0.8,
|
|
"neuroticism": 0.4,
|
|
"bias_strength": 0.6
|
|
}
|
|
}
|
|
})
|
|
|
|
background: str
|
|
personality: Optional[PersonalityTraits] = None
|
|
|
|
|
|
class AgentListItem(BaseModel):
|
|
"""Agent list item with profile summary."""
|
|
agent_id: str
|
|
name: str
|
|
personality: PersonalityTraits
|
|
background: str
|
|
created_at: Optional[str] = None
|
|
updated_at: Optional[str] = None
|
|
|
|
|
|
class AgentListResponse(BaseModel):
|
|
"""Response model for listing all agents."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"agents": [
|
|
{
|
|
"agent_id": "user123",
|
|
"name": "Alice",
|
|
"personality": {
|
|
"openness": 0.5,
|
|
"conscientiousness": 0.5,
|
|
"extraversion": 0.5,
|
|
"agreeableness": 0.5,
|
|
"neuroticism": 0.5,
|
|
"bias_strength": 0.5
|
|
},
|
|
"background": "I am a software engineer",
|
|
"created_at": "2024-01-15T10:30:00Z",
|
|
"updated_at": "2024-01-16T14:20:00Z"
|
|
}
|
|
]
|
|
}
|
|
})
|
|
|
|
agents: List[AgentListItem]
|
|
|
|
|
|
class CreateAgentRequest(BaseModel):
|
|
"""Request model for creating/updating an agent."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"name": "Alice",
|
|
"personality": {
|
|
"openness": 0.8,
|
|
"conscientiousness": 0.6,
|
|
"extraversion": 0.5,
|
|
"agreeableness": 0.7,
|
|
"neuroticism": 0.3,
|
|
"bias_strength": 0.7
|
|
},
|
|
"background": "I am a creative software engineer with 10 years of experience"
|
|
}
|
|
})
|
|
|
|
name: Optional[str] = None
|
|
personality: Optional[PersonalityTraits] = None
|
|
background: Optional[str] = None
|
|
|
|
|
|
class GraphDataResponse(BaseModel):
|
|
"""Response model for graph data endpoint."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"nodes": [
|
|
{"id": "1", "label": "Alice works at Google", "type": "world"},
|
|
{"id": "2", "label": "Bob went hiking", "type": "world"}
|
|
],
|
|
"edges": [
|
|
{"from": "1", "to": "2", "type": "semantic", "weight": 0.8}
|
|
],
|
|
"table_rows": [
|
|
{"id": "abc12345...", "text": "Alice works at Google", "context": "Work info", "date": "2024-01-15 10:30", "entities": "Alice (PERSON), Google (ORGANIZATION)"}
|
|
],
|
|
"total_units": 2
|
|
}
|
|
})
|
|
|
|
nodes: List[Dict[str, Any]]
|
|
edges: List[Dict[str, Any]]
|
|
table_rows: List[Dict[str, Any]]
|
|
total_units: int
|
|
|
|
|
|
class ListMemoryUnitsResponse(BaseModel):
|
|
"""Response model for list memory units endpoint."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"items": [
|
|
{
|
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
"text": "Alice works at Google on the AI team",
|
|
"context": "Work conversation",
|
|
"date": "2024-01-15T10:30:00Z",
|
|
"fact_type": "world",
|
|
"entities": "Alice (PERSON), Google (ORGANIZATION)"
|
|
}
|
|
],
|
|
"total": 150,
|
|
"limit": 100,
|
|
"offset": 0
|
|
}
|
|
})
|
|
|
|
items: List[Dict[str, Any]]
|
|
total: int
|
|
limit: int
|
|
offset: int
|
|
|
|
|
|
class ListDocumentsResponse(BaseModel):
|
|
"""Response model for list documents endpoint."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"items": [
|
|
{
|
|
"id": "session_1",
|
|
"agent_id": "user123",
|
|
"content_hash": "abc123",
|
|
"created_at": "2024-01-15T10:30:00Z",
|
|
"updated_at": "2024-01-15T10:30:00Z",
|
|
"text_length": 5420,
|
|
"memory_unit_count": 15
|
|
}
|
|
],
|
|
"total": 50,
|
|
"limit": 100,
|
|
"offset": 0
|
|
}
|
|
})
|
|
|
|
items: List[Dict[str, Any]]
|
|
total: int
|
|
limit: int
|
|
offset: int
|
|
|
|
|
|
class DocumentResponse(BaseModel):
|
|
"""Response model for get document endpoint."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"id": "session_1",
|
|
"agent_id": "user123",
|
|
"original_text": "Full document text here...",
|
|
"content_hash": "abc123",
|
|
"created_at": "2024-01-15T10:30:00Z",
|
|
"updated_at": "2024-01-15T10:30:00Z",
|
|
"memory_unit_count": 15
|
|
}
|
|
})
|
|
|
|
id: str
|
|
agent_id: str
|
|
original_text: str
|
|
content_hash: Optional[str]
|
|
created_at: str
|
|
updated_at: str
|
|
memory_unit_count: int
|
|
|
|
|
|
class DeleteResponse(BaseModel):
|
|
"""Response model for delete operations."""
|
|
model_config = ConfigDict(json_schema_extra={
|
|
"example": {
|
|
"success": True,
|
|
"message": "Resource deleted successfully"
|
|
}
|
|
})
|
|
|
|
success: bool
|
|
message: str
|
|
|
|
|
|
def create_app(memory: MemoryEngine, run_migrations: bool = True, initialize_memory: bool = True) -> FastAPI:
|
|
"""
|
|
Create and configure the FastAPI application.
|
|
|
|
Args:
|
|
memory: MemoryEngine instance (already initialized with required parameters)
|
|
run_migrations: Whether to run database migrations on startup (default: True)
|
|
initialize_memory: Whether to initialize memory system on startup (default: True)
|
|
|
|
Returns:
|
|
Configured FastAPI application
|
|
|
|
Note:
|
|
When mounting this app as a sub-application, the lifespan events may not fire.
|
|
In that case, you should call memory.initialize() manually before starting the server
|
|
and memory.close() when shutting down.
|
|
"""
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""
|
|
Lifespan context manager for startup and shutdown events.
|
|
Note: This only fires when running the app standalone, not when mounted.
|
|
"""
|
|
# Startup: Initialize database and memory system
|
|
if initialize_memory:
|
|
await memory.initialize()
|
|
logging.info("Memory system initialized")
|
|
|
|
if run_migrations:
|
|
from hindsight_api.migrations import run_migrations as do_migrations
|
|
do_migrations(memory.db_url)
|
|
logging.info("Database migrations applied")
|
|
|
|
|
|
|
|
yield
|
|
|
|
# Shutdown: Cleanup memory system
|
|
await memory.close()
|
|
logging.info("Memory system closed")
|
|
|
|
app = FastAPI(
|
|
title="Agent Memory API",
|
|
version="1.0.0",
|
|
description="""
|
|
A temporal-semantic memory system for AI agents that stores, retrieves, and reasons over memories.
|
|
|
|
## Features
|
|
|
|
* **Batch Memory Storage**: Store multiple memories efficiently with automatic fact extraction
|
|
* **Semantic Search**: Find relevant memories using natural language queries
|
|
* **Fact Type Filtering**: Search across world facts, agent actions, and opinions separately
|
|
* **Think Endpoint**: Generate contextual answers based on agent identity and memories
|
|
* **Graph Visualization**: Interactive memory graph visualization
|
|
* **Document Tracking**: Track and manage memory documents with upsert support
|
|
|
|
## Architecture
|
|
|
|
The system uses:
|
|
- **Temporal Links**: Connect memories that are close in time
|
|
- **Semantic Links**: Connect semantically similar memories
|
|
- **Entity Links**: Connect memories that mention the same entities
|
|
- **Spreading Activation**: Intelligent traversal for memory retrieval
|
|
""",
|
|
contact={
|
|
"name": "Memory System",
|
|
},
|
|
license_info={
|
|
"name": "Apache 2.0",
|
|
"url": "https://www.apache.org/licenses/LICENSE-2.0.html",
|
|
},
|
|
lifespan=lifespan
|
|
)
|
|
|
|
# IMPORTANT: Set memory on app.state immediately, don't wait for lifespan
|
|
# This is required for mounted sub-applications where lifespan may not fire
|
|
app.state.memory = memory
|
|
|
|
# Register all routes
|
|
_register_routes(app)
|
|
|
|
return app
|
|
|
|
|
|
def _register_routes(app: FastAPI):
|
|
"""Register all API routes on the given app instance."""
|
|
|
|
|
|
@app.get(
|
|
"/api/v1/agents/{agent_id}/graph",
|
|
response_model=GraphDataResponse,
|
|
tags=["Visualization"],
|
|
summary="Get memory graph data",
|
|
description="Retrieve graph data for visualization, optionally filtered by fact_type (world/agent/opinion). Limited to 1000 most recent items.",
|
|
operation_id="get_graph"
|
|
)
|
|
async def api_graph(
|
|
agent_id: str,
|
|
fact_type: Optional[str] = None
|
|
):
|
|
"""Get graph data from database, filtered by agent_id and optionally by fact_type."""
|
|
try:
|
|
data = await app.state.memory.get_graph_data(agent_id, fact_type)
|
|
return data
|
|
except Exception as e:
|
|
import traceback
|
|
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
print(f"Error in /api/v1/agents/{agent_id}/graph: {error_detail}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get(
|
|
"/api/v1/agents/{agent_id}/memories/list",
|
|
response_model=ListMemoryUnitsResponse,
|
|
tags=["Memory Operations"],
|
|
summary="List memory units",
|
|
description="List memory units with pagination and optional full-text search. Supports filtering by fact_type.",
|
|
operation_id="list_memories"
|
|
)
|
|
async def api_list(
|
|
agent_id: str,
|
|
fact_type: Optional[str] = None,
|
|
q: Optional[str] = None,
|
|
limit: int = 100,
|
|
offset: int = 0
|
|
):
|
|
"""
|
|
List memory units for table view with optional full-text search.
|
|
|
|
Args:
|
|
agent_id: Agent ID (from path)
|
|
fact_type: Filter by fact type (world, agent, opinion)
|
|
q: Search query for full-text search (searches text and context)
|
|
limit: Maximum number of results (default: 100)
|
|
offset: Offset for pagination (default: 0)
|
|
"""
|
|
try:
|
|
data = await app.state.memory.list_memory_units(
|
|
agent_id=agent_id,
|
|
fact_type=fact_type,
|
|
search_query=q,
|
|
limit=limit,
|
|
offset=offset
|
|
)
|
|
return data
|
|
except Exception as e:
|
|
import traceback
|
|
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
print(f"Error in /api/v1/agents/{agent_id}/memories/list: {error_detail}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post(
|
|
"/api/v1/agents/{agent_id}/memories/search",
|
|
response_model=SearchResponse,
|
|
tags=["Memory Operations"],
|
|
summary="Search memory",
|
|
description="""
|
|
Search memory using semantic similarity and spreading activation.
|
|
|
|
The fact_type parameter is optional and must be one of:
|
|
- 'world': General knowledge about people, places, events, and things that happen
|
|
- 'agent': Memories about what the AI agent did, actions taken, and tasks performed
|
|
- 'opinion': The agent's formed beliefs, perspectives, and viewpoints
|
|
- 'observation': Synthesized observations about entities (generated automatically)
|
|
|
|
Set include_entities=true to get entity observations alongside search results.
|
|
""",
|
|
operation_id="search_memories"
|
|
)
|
|
async def api_search(agent_id: str, request: SearchRequest):
|
|
"""Run a search and return results with trace."""
|
|
try:
|
|
# Validate fact_type(s)
|
|
valid_fact_types = ["world", "agent", "opinion", "observation"]
|
|
|
|
# Default to world, agent, opinion if not specified (exclude observation by default)
|
|
if not request.fact_type:
|
|
request.fact_type = ["world", "agent", "opinion"]
|
|
else:
|
|
for ft in request.fact_type:
|
|
if ft not in valid_fact_types:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Invalid fact_type '{ft}'. Must be one of: {', '.join(valid_fact_types)}"
|
|
)
|
|
|
|
# Parse question_date if provided
|
|
question_date = None
|
|
if request.question_date:
|
|
try:
|
|
question_date = datetime.fromisoformat(request.question_date.replace('Z', '+00:00'))
|
|
except ValueError as e:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Invalid question_date format. Expected ISO format (e.g., '2023-05-30T23:40:00'): {str(e)}"
|
|
)
|
|
|
|
# Run search with tracing
|
|
core_result = await app.state.memory.search_async(
|
|
agent_id=agent_id,
|
|
query=request.query,
|
|
thinking_budget=request.thinking_budget,
|
|
max_tokens=request.max_tokens,
|
|
enable_trace=request.trace,
|
|
fact_type=request.fact_type,
|
|
question_date=question_date,
|
|
include_entities=request.include_entities,
|
|
max_entity_tokens=request.max_entity_tokens
|
|
)
|
|
|
|
# Convert core MemoryFact objects to API SearchResult objects (excluding internal metrics)
|
|
search_results = [
|
|
SearchResult(
|
|
id=fact.id,
|
|
text=fact.text,
|
|
type=fact.fact_type,
|
|
entities=fact.entities,
|
|
context=fact.context,
|
|
occurred_start=fact.occurred_start,
|
|
occurred_end=fact.occurred_end,
|
|
mentioned_at=fact.mentioned_at,
|
|
document_id=fact.document_id
|
|
)
|
|
for fact in core_result.results
|
|
]
|
|
|
|
# Convert core EntityState objects to API EntityStateResponse objects
|
|
entities_response = None
|
|
if core_result.entities:
|
|
entities_response = {}
|
|
for name, state in core_result.entities.items():
|
|
entities_response[name] = EntityStateResponse(
|
|
entity_id=state.entity_id,
|
|
canonical_name=state.canonical_name,
|
|
observations=[
|
|
EntityObservationResponse(text=obs.text, mentioned_at=obs.mentioned_at)
|
|
for obs in state.observations
|
|
]
|
|
)
|
|
|
|
return SearchResponse(
|
|
results=search_results,
|
|
trace=core_result.trace,
|
|
entities=entities_response
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
import traceback
|
|
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
print(f"Error in /api/v1/agents/{agent_id}/memories/search: {error_detail}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post(
|
|
"/api/v1/agents/{agent_id}/think",
|
|
response_model=ThinkResponse,
|
|
tags=["Reasoning"],
|
|
summary="Think and generate answer",
|
|
description="""
|
|
Think and formulate an answer using agent identity, world facts, and opinions.
|
|
|
|
This endpoint:
|
|
1. Retrieves agent facts (agent's identity)
|
|
2. Retrieves world facts relevant to the query
|
|
3. Retrieves existing opinions (agent's perspectives)
|
|
4. Uses LLM to formulate a contextual answer
|
|
5. Extracts and stores any new opinions formed
|
|
6. Returns plain text answer, the facts used, and new opinions
|
|
""",
|
|
operation_id="think"
|
|
)
|
|
async def api_think(agent_id: str, request: ThinkRequest):
|
|
try:
|
|
# Use the memory system's think_async method
|
|
core_result = await app.state.memory.think_async(
|
|
agent_id=agent_id,
|
|
query=request.query,
|
|
thinking_budget=request.thinking_budget,
|
|
context=request.context
|
|
)
|
|
|
|
# Convert core MemoryFact objects to API ThinkFact objects (excluding internal metrics)
|
|
based_on_facts = []
|
|
for fact_type, facts in core_result.based_on.items():
|
|
for fact in facts:
|
|
based_on_facts.append(ThinkFact(
|
|
id=fact.id,
|
|
text=fact.text,
|
|
type=fact.fact_type,
|
|
context=fact.context,
|
|
occurred_start=fact.occurred_start,
|
|
occurred_end=fact.occurred_end
|
|
))
|
|
|
|
return ThinkResponse(
|
|
text=core_result.text,
|
|
based_on=based_on_facts,
|
|
new_opinions=core_result.new_opinions
|
|
)
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
print(f"Error in /api/v1/agents/{agent_id}/think: {error_detail}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get(
|
|
"/api/v1/agents",
|
|
response_model=AgentListResponse,
|
|
tags=["Agent Management"],
|
|
summary="List all agents",
|
|
description="Get a list of all agents with their profiles",
|
|
operation_id="list_agents"
|
|
)
|
|
async def api_agents():
|
|
"""Get list of all agents with their profiles."""
|
|
try:
|
|
agents = await app.state.memory.list_agents()
|
|
return AgentListResponse(agents=agents)
|
|
except Exception as e:
|
|
import traceback
|
|
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
print(f"Error in /api/v1/agents: {error_detail}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@app.get(
|
|
"/api/v1/agents/{agent_id}/stats",
|
|
tags=["Agent Management"],
|
|
summary="Get memory statistics for an agent",
|
|
description="Get statistics about nodes and links for a specific agent",
|
|
operation_id="get_agent_stats"
|
|
)
|
|
async def api_stats(agent_id: str):
|
|
"""Get statistics about memory nodes and links for an agent."""
|
|
try:
|
|
pool = await app.state.memory._get_pool()
|
|
async with acquire_with_retry(pool) as conn:
|
|
# Get node counts by fact_type
|
|
node_stats = await conn.fetch(
|
|
"""
|
|
SELECT fact_type, COUNT(*) as count
|
|
FROM memory_units
|
|
WHERE agent_id = $1
|
|
GROUP BY fact_type
|
|
""",
|
|
agent_id
|
|
)
|
|
|
|
# Get link counts by link_type
|
|
link_stats = await conn.fetch(
|
|
"""
|
|
SELECT ml.link_type, COUNT(*) as count
|
|
FROM memory_links ml
|
|
JOIN memory_units mu ON ml.from_unit_id = mu.id
|
|
WHERE mu.agent_id = $1
|
|
GROUP BY ml.link_type
|
|
""",
|
|
agent_id
|
|
)
|
|
|
|
# Get link counts by fact_type (from nodes)
|
|
link_fact_type_stats = await conn.fetch(
|
|
"""
|
|
SELECT mu.fact_type, COUNT(*) as count
|
|
FROM memory_links ml
|
|
JOIN memory_units mu ON ml.from_unit_id = mu.id
|
|
WHERE mu.agent_id = $1
|
|
GROUP BY mu.fact_type
|
|
""",
|
|
agent_id
|
|
)
|
|
|
|
# Get link counts by fact_type AND link_type
|
|
link_breakdown_stats = await conn.fetch(
|
|
"""
|
|
SELECT mu.fact_type, ml.link_type, COUNT(*) as count
|
|
FROM memory_links ml
|
|
JOIN memory_units mu ON ml.from_unit_id = mu.id
|
|
WHERE mu.agent_id = $1
|
|
GROUP BY mu.fact_type, ml.link_type
|
|
""",
|
|
agent_id
|
|
)
|
|
|
|
# Get pending and failed operations counts
|
|
ops_stats = await conn.fetch(
|
|
"""
|
|
SELECT status, COUNT(*) as count
|
|
FROM async_operations
|
|
WHERE agent_id = $1
|
|
GROUP BY status
|
|
""",
|
|
agent_id
|
|
)
|
|
ops_by_status = {row['status']: row['count'] for row in ops_stats}
|
|
pending_operations = ops_by_status.get('pending', 0)
|
|
failed_operations = ops_by_status.get('failed', 0)
|
|
|
|
# Get document count
|
|
doc_count_result = await conn.fetchrow(
|
|
"""
|
|
SELECT COUNT(*) as count
|
|
FROM documents
|
|
WHERE agent_id = $1
|
|
""",
|
|
agent_id
|
|
)
|
|
total_documents = doc_count_result['count'] if doc_count_result else 0
|
|
|
|
# Format results
|
|
nodes_by_type = {row['fact_type']: row['count'] for row in node_stats}
|
|
links_by_type = {row['link_type']: row['count'] for row in link_stats}
|
|
links_by_fact_type = {row['fact_type']: row['count'] for row in link_fact_type_stats}
|
|
|
|
# Build detailed breakdown: {fact_type: {link_type: count}}
|
|
links_breakdown = {}
|
|
for row in link_breakdown_stats:
|
|
fact_type = row['fact_type']
|
|
link_type = row['link_type']
|
|
count = row['count']
|
|
if fact_type not in links_breakdown:
|
|
links_breakdown[fact_type] = {}
|
|
links_breakdown[fact_type][link_type] = count
|
|
|
|
total_nodes = sum(nodes_by_type.values())
|
|
total_links = sum(links_by_type.values())
|
|
|
|
return {
|
|
"agent_id": agent_id,
|
|
"total_nodes": total_nodes,
|
|
"total_links": total_links,
|
|
"total_documents": total_documents,
|
|
"nodes_by_fact_type": nodes_by_type,
|
|
"links_by_link_type": links_by_type,
|
|
"links_by_fact_type": links_by_fact_type,
|
|
"links_breakdown": links_breakdown,
|
|
"pending_operations": pending_operations,
|
|
"failed_operations": failed_operations
|
|
}
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
print(f"Error in /api/v1/agents/{agent_id}/stats: {error_detail}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@app.get(
|
|
"/api/v1/agents/{agent_id}/entities",
|
|
response_model=EntityListResponse,
|
|
tags=["Entities"],
|
|
summary="List entities",
|
|
description="List all entities (people, organizations, etc.) known by the agent, ordered by mention count.",
|
|
operation_id="list_entities"
|
|
)
|
|
async def api_list_entities(
|
|
agent_id: str,
|
|
limit: int = Query(default=100, description="Maximum number of entities to return")
|
|
):
|
|
"""List entities for an agent."""
|
|
try:
|
|
entities = await app.state.memory.list_entities(agent_id, limit=limit)
|
|
return EntityListResponse(
|
|
entities=[EntityListItem(**e) for e in entities]
|
|
)
|
|
except Exception as e:
|
|
import traceback
|
|
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
print(f"Error in /api/v1/agents/{agent_id}/entities: {error_detail}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@app.get(
|
|
"/api/v1/agents/{agent_id}/entities/{entity_id}",
|
|
response_model=EntityDetailResponse,
|
|
tags=["Entities"],
|
|
summary="Get entity details",
|
|
description="Get detailed information about an entity including observations (mental model).",
|
|
operation_id="get_entity"
|
|
)
|
|
async def api_get_entity(agent_id: str, entity_id: str):
|
|
"""Get entity details with observations."""
|
|
try:
|
|
# First get the entity metadata
|
|
pool = await app.state.memory._get_pool()
|
|
async with acquire_with_retry(pool) as conn:
|
|
entity_row = await conn.fetchrow(
|
|
"""
|
|
SELECT id, canonical_name, mention_count, first_seen, last_seen, metadata
|
|
FROM entities
|
|
WHERE agent_id = $1 AND id = $2
|
|
""",
|
|
agent_id, uuid.UUID(entity_id)
|
|
)
|
|
|
|
if not entity_row:
|
|
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
|
|
|
|
# Get observations for the entity
|
|
observations = await app.state.memory.get_entity_observations(
|
|
agent_id, entity_id, limit=20
|
|
)
|
|
|
|
return EntityDetailResponse(
|
|
id=str(entity_row['id']),
|
|
canonical_name=entity_row['canonical_name'],
|
|
mention_count=entity_row['mention_count'],
|
|
first_seen=entity_row['first_seen'].isoformat() if entity_row['first_seen'] else None,
|
|
last_seen=entity_row['last_seen'].isoformat() if entity_row['last_seen'] else None,
|
|
metadata=_parse_metadata(entity_row['metadata']),
|
|
observations=[
|
|
EntityObservationResponse(text=obs.text, mentioned_at=obs.mentioned_at)
|
|
for obs in observations
|
|
]
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
import traceback
|
|
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
print(f"Error in /api/v1/agents/{agent_id}/entities/{entity_id}: {error_detail}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@app.post(
|
|
"/api/v1/agents/{agent_id}/entities/{entity_id}/regenerate",
|
|
response_model=EntityDetailResponse,
|
|
tags=["Entities"],
|
|
summary="Regenerate entity observations",
|
|
description="Regenerate observations for an entity based on all facts mentioning it.",
|
|
operation_id="regenerate_entity_observations"
|
|
)
|
|
async def api_regenerate_entity_observations(agent_id: str, entity_id: str):
|
|
"""Regenerate observations for an entity."""
|
|
try:
|
|
# First get the entity metadata
|
|
pool = await app.state.memory._get_pool()
|
|
async with acquire_with_retry(pool) as conn:
|
|
entity_row = await conn.fetchrow(
|
|
"""
|
|
SELECT id, canonical_name, mention_count, first_seen, last_seen, metadata
|
|
FROM entities
|
|
WHERE agent_id = $1 AND id = $2
|
|
""",
|
|
agent_id, uuid.UUID(entity_id)
|
|
)
|
|
|
|
if not entity_row:
|
|
raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found")
|
|
|
|
# Regenerate observations
|
|
await app.state.memory.regenerate_entity_observations(
|
|
agent_id=agent_id,
|
|
entity_id=entity_id,
|
|
entity_name=entity_row['canonical_name']
|
|
)
|
|
|
|
# Get updated observations
|
|
observations = await app.state.memory.get_entity_observations(
|
|
agent_id, entity_id, limit=20
|
|
)
|
|
|
|
return EntityDetailResponse(
|
|
id=str(entity_row['id']),
|
|
canonical_name=entity_row['canonical_name'],
|
|
mention_count=entity_row['mention_count'],
|
|
first_seen=entity_row['first_seen'].isoformat() if entity_row['first_seen'] else None,
|
|
last_seen=entity_row['last_seen'].isoformat() if entity_row['last_seen'] else None,
|
|
metadata=_parse_metadata(entity_row['metadata']),
|
|
observations=[
|
|
EntityObservationResponse(text=obs.text, mentioned_at=obs.mentioned_at)
|
|
for obs in observations
|
|
]
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
import traceback
|
|
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
print(f"Error in /api/v1/agents/{agent_id}/entities/{entity_id}/regenerate: {error_detail}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
@app.get(
|
|
"/api/v1/agents/{agent_id}/documents",
|
|
response_model=ListDocumentsResponse,
|
|
tags=["Documents"],
|
|
summary="List documents",
|
|
description="List documents with pagination and optional search. Documents are the source content from which memory units are extracted.",
|
|
operation_id="list_documents"
|
|
)
|
|
async def api_list_documents(
|
|
agent_id: str,
|
|
q: Optional[str] = None,
|
|
limit: int = 100,
|
|
offset: int = 0
|
|
):
|
|
"""
|
|
List documents for an agent with optional search.
|
|
|
|
Args:
|
|
agent_id: Agent ID (from path)
|
|
q: Search query (searches document ID and metadata)
|
|
limit: Maximum number of results (default: 100)
|
|
offset: Offset for pagination (default: 0)
|
|
"""
|
|
try:
|
|
data = await app.state.memory.list_documents(
|
|
agent_id=agent_id,
|
|
search_query=q,
|
|
limit=limit,
|
|
offset=offset
|
|
)
|
|
return data
|
|
except Exception as e:
|
|
import traceback
|
|
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
print(f"Error in /api/v1/agents/{agent_id}/documents: {error_detail}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get(
|
|
"/api/v1/agents/{agent_id}/documents/{document_id}",
|
|
response_model=DocumentResponse,
|
|
tags=["Documents"],
|
|
summary="Get document details",
|
|
description="Get a specific document including its original text",
|
|
operation_id="get_document"
|
|
)
|
|
async def api_get_document(
|
|
agent_id: str,
|
|
document_id: str
|
|
):
|
|
"""
|
|
Get a specific document with its original text.
|
|
|
|
Args:
|
|
agent_id: Agent ID (from path)
|
|
document_id: Document ID (from path)
|
|
"""
|
|
try:
|
|
document = await app.state.memory.get_document(document_id, agent_id)
|
|
if not document:
|
|
raise HTTPException(status_code=404, detail="Document not found")
|
|
return document
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
import traceback
|
|
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
print(f"Error in /api/v1/agents/{agent_id}/documents/{document_id}: {error_detail}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.delete(
|
|
"/api/v1/agents/{agent_id}/documents/{document_id}",
|
|
tags=["Documents"],
|
|
summary="Delete a document",
|
|
description="""
|
|
Delete a document and all its associated memory units and links.
|
|
|
|
This will cascade delete:
|
|
- The document itself
|
|
- All memory units extracted from this document
|
|
- All links (temporal, semantic, entity) associated with those memory units
|
|
|
|
This operation cannot be undone.
|
|
""",
|
|
operation_id="delete_document"
|
|
)
|
|
async def api_delete_document(
|
|
agent_id: str,
|
|
document_id: str
|
|
):
|
|
"""
|
|
Delete a document and all its associated memory units and links.
|
|
|
|
Args:
|
|
agent_id: Agent ID (from path)
|
|
document_id: Document ID to delete (from path)
|
|
"""
|
|
try:
|
|
result = await app.state.memory.delete_document(document_id, agent_id)
|
|
|
|
if result["document_deleted"] == 0:
|
|
raise HTTPException(status_code=404, detail="Document not found")
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Document '{document_id}' and {result['memory_units_deleted']} associated memory units deleted successfully",
|
|
"document_id": document_id,
|
|
"memory_units_deleted": result["memory_units_deleted"]
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
import traceback
|
|
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
print(f"Error in /api/v1/agents/{agent_id}/documents/{document_id}: {error_detail}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post(
|
|
"/api/v1/agents/{agent_id}/memories",
|
|
response_model=BatchPutResponse,
|
|
tags=["Memory Operations"],
|
|
summary="Store multiple memories",
|
|
description="""
|
|
Store multiple memory items in batch with automatic fact extraction.
|
|
|
|
Features:
|
|
- Efficient batch processing
|
|
- Automatic fact extraction from natural language
|
|
- Entity recognition and linking
|
|
- Document tracking with automatic upsert (when document_id is provided)
|
|
- Temporal and semantic linking
|
|
|
|
The system automatically:
|
|
1. Extracts semantic facts from the content
|
|
2. Generates embeddings
|
|
3. Deduplicates similar facts
|
|
4. Creates temporal, semantic, and entity links
|
|
5. Tracks document metadata
|
|
|
|
Note: If document_id is provided and already exists, the old document and its memory units will be deleted before creating new ones (upsert behavior).
|
|
""",
|
|
operation_id="batch_put_memories"
|
|
)
|
|
async def api_batch_put(agent_id: str, request: BatchPutRequest):
|
|
try:
|
|
# Prepare contents for put_batch_async
|
|
contents = []
|
|
for item in request.items:
|
|
content_dict = {"content": item.content}
|
|
if item.event_date:
|
|
content_dict["event_date"] = item.event_date
|
|
if item.context:
|
|
content_dict["context"] = item.context
|
|
contents.append(content_dict)
|
|
|
|
# Call put_batch_async
|
|
result = await app.state.memory.put_batch_async(
|
|
agent_id=agent_id,
|
|
contents=contents,
|
|
document_id=request.document_id
|
|
)
|
|
|
|
|
|
return BatchPutResponse(
|
|
success=True,
|
|
message=f"Successfully stored {len(contents)} memory items",
|
|
agent_id=agent_id,
|
|
document_id=request.document_id,
|
|
items_count=len(contents)
|
|
)
|
|
except Exception as e:
|
|
import traceback
|
|
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
print(f"Error in /api/v1/agents/{agent_id}/memories: {error_detail}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post(
|
|
"/api/v1/agents/{agent_id}/memories/async",
|
|
response_model=BatchPutAsyncResponse,
|
|
tags=["Memory Operations"],
|
|
summary="Store multiple memories asynchronously",
|
|
description="""
|
|
Store multiple memory items in batch asynchronously using the task backend.
|
|
|
|
This endpoint returns immediately after queuing the task, without waiting for completion.
|
|
The actual processing happens in the background.
|
|
|
|
Features:
|
|
- Immediate response (non-blocking)
|
|
- Background processing via task queue
|
|
- Efficient batch processing
|
|
- Automatic fact extraction from natural language
|
|
- Entity recognition and linking
|
|
- Document tracking with automatic upsert (when document_id is provided)
|
|
- Temporal and semantic linking
|
|
|
|
The system automatically:
|
|
1. Queues the batch put task
|
|
2. Returns immediately with success=True, queued=True
|
|
3. Processes in background: extracts facts, generates embeddings, creates links
|
|
|
|
Note: If document_id is provided and already exists, the old document and its memory units will be deleted before creating new ones (upsert behavior).
|
|
""",
|
|
operation_id="batch_put_async"
|
|
)
|
|
async def api_batch_put_async(agent_id: str, request: BatchPutRequest):
|
|
try:
|
|
# Prepare contents for put_batch_async
|
|
contents = []
|
|
for item in request.items:
|
|
content_dict = {"content": item.content}
|
|
if item.event_date:
|
|
content_dict["event_date"] = item.event_date
|
|
if item.context:
|
|
content_dict["context"] = item.context
|
|
contents.append(content_dict)
|
|
|
|
# Generate UUID for this operation
|
|
operation_id = uuid.uuid4()
|
|
|
|
# Insert operation record into database BEFORE scheduling task
|
|
pool = await app.state.memory._get_pool()
|
|
async with acquire_with_retry(pool) as conn:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO async_operations (id, agent_id, task_type, items_count, document_id)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
""",
|
|
operation_id,
|
|
agent_id,
|
|
'batch_put',
|
|
len(contents),
|
|
request.document_id
|
|
)
|
|
|
|
# Submit task to background queue with operation_id
|
|
await app.state.memory._task_backend.submit_task({
|
|
'type': 'batch_put',
|
|
'operation_id': str(operation_id),
|
|
'agent_id': agent_id,
|
|
'contents': contents,
|
|
'document_id': request.document_id
|
|
})
|
|
|
|
logging.info(f"Batch put task queued for agent_id={agent_id}, {len(contents)} items, operation_id={operation_id}")
|
|
|
|
return BatchPutAsyncResponse(
|
|
success=True,
|
|
message=f"Batch put task queued for background processing ({len(contents)} items)",
|
|
agent_id=agent_id,
|
|
document_id=request.document_id,
|
|
items_count=len(contents),
|
|
queued=True
|
|
)
|
|
except Exception as e:
|
|
import traceback
|
|
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
print(f"Error in /api/v1/agents/{agent_id}/memories/async: {error_detail}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.get(
|
|
"/api/v1/agents/{agent_id}/operations",
|
|
tags=["Memory Operations"],
|
|
summary="List async operations",
|
|
description="Get a list of all async operations (pending and failed) for a specific agent, including error messages for failed operations",
|
|
operation_id="list_operations"
|
|
)
|
|
async def api_list_operations(agent_id: str):
|
|
"""List all async operations (pending and failed) for an agent."""
|
|
try:
|
|
pool = await app.state.memory._get_pool()
|
|
async with acquire_with_retry(pool) as conn:
|
|
operations = await conn.fetch(
|
|
"""
|
|
SELECT id, agent_id, task_type, items_count, document_id, created_at, status, error_message
|
|
FROM async_operations
|
|
WHERE agent_id = $1
|
|
ORDER BY created_at ASC
|
|
""",
|
|
agent_id
|
|
)
|
|
|
|
return {
|
|
"agent_id": agent_id,
|
|
"operations": [
|
|
{
|
|
"id": str(row['id']),
|
|
"task_type": row['task_type'],
|
|
"items_count": row['items_count'],
|
|
"document_id": row['document_id'],
|
|
"created_at": row['created_at'].isoformat(),
|
|
"status": row['status'],
|
|
"error_message": row['error_message']
|
|
}
|
|
for row in operations
|
|
]
|
|
}
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
print(f"Error in /api/v1/agents/{agent_id}/operations: {error_detail}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.delete(
|
|
"/api/v1/agents/{agent_id}/operations/{operation_id}",
|
|
tags=["Memory Operations"],
|
|
summary="Cancel a pending async operation",
|
|
description="Cancel a pending async operation by removing it from the queue",
|
|
operation_id="cancel_operation"
|
|
)
|
|
async def api_cancel_operation(agent_id: str, operation_id: str):
|
|
"""Cancel a pending async operation."""
|
|
try:
|
|
# Validate UUID format
|
|
try:
|
|
op_uuid = uuid.UUID(operation_id)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail=f"Invalid operation_id format: {operation_id}")
|
|
|
|
pool = await app.state.memory._get_pool()
|
|
async with acquire_with_retry(pool) as conn:
|
|
# Check if operation exists and belongs to this agent
|
|
result = await conn.fetchrow(
|
|
"SELECT agent_id FROM async_operations WHERE id = $1 AND agent_id = $2",
|
|
op_uuid,
|
|
agent_id
|
|
)
|
|
|
|
if not result:
|
|
raise HTTPException(status_code=404, detail=f"Operation {operation_id} not found for agent {agent_id}")
|
|
|
|
# Delete the operation
|
|
await conn.execute(
|
|
"DELETE FROM async_operations WHERE id = $1",
|
|
op_uuid
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Operation {operation_id} cancelled",
|
|
"operation_id": operation_id,
|
|
"agent_id": agent_id
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
import traceback
|
|
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
print(f"Error in /api/v1/agents/{agent_id}/operations/{operation_id}: {error_detail}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.delete(
|
|
"/api/v1/agents/{agent_id}/memories/{unit_id}",
|
|
tags=["Memory Operations"],
|
|
summary="Delete a memory unit",
|
|
description="Delete a single memory unit and all its associated links (temporal, semantic, and entity links)",
|
|
operation_id="delete_memory_unit"
|
|
)
|
|
async def api_delete_memory_unit(agent_id: str, unit_id: str):
|
|
"""Delete a memory unit and all its links."""
|
|
try:
|
|
result = await app.state.memory.delete_memory_unit(unit_id)
|
|
|
|
if not result["success"]:
|
|
raise HTTPException(status_code=404, detail=result["message"])
|
|
|
|
return result
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
import traceback
|
|
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
print(f"Error in /api/v1/agents/{agent_id}/memories/{unit_id}: {error_detail}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
# Agent Profile Endpoints
|
|
|
|
@app.get(
|
|
"/api/v1/agents/{agent_id}/profile",
|
|
response_model=AgentProfileResponse,
|
|
tags=["Agent Management"],
|
|
summary="Get agent profile",
|
|
description="Get personality traits and background for an agent. Auto-creates agent with defaults if not exists.",
|
|
operation_id="get_agent_profile"
|
|
)
|
|
async def api_get_agent_profile(agent_id: str):
|
|
"""Get agent profile (personality + background)."""
|
|
try:
|
|
profile = await app.state.memory.get_agent_profile(agent_id)
|
|
return AgentProfileResponse(
|
|
agent_id=agent_id,
|
|
name=profile["name"],
|
|
personality=PersonalityTraits(**profile["personality"]),
|
|
background=profile["background"]
|
|
)
|
|
except Exception as e:
|
|
import traceback
|
|
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
print(f"Error in /api/v1/agents/{agent_id}/profile: {error_detail}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.put(
|
|
"/api/v1/agents/{agent_id}/profile",
|
|
response_model=AgentProfileResponse,
|
|
tags=["Agent Management"],
|
|
summary="Update agent personality",
|
|
description="Update agent's Big Five personality traits and bias strength",
|
|
operation_id="update_agent_personality"
|
|
)
|
|
async def api_update_agent_personality(
|
|
agent_id: str,
|
|
request: UpdatePersonalityRequest
|
|
):
|
|
"""Update agent personality traits."""
|
|
try:
|
|
# Update personality
|
|
await app.state.memory.update_agent_personality(
|
|
agent_id,
|
|
request.personality.model_dump()
|
|
)
|
|
|
|
# Get updated profile
|
|
profile = await app.state.memory.get_agent_profile(agent_id)
|
|
return AgentProfileResponse(
|
|
agent_id=agent_id,
|
|
name=profile["name"],
|
|
personality=PersonalityTraits(**profile["personality"]),
|
|
background=profile["background"]
|
|
)
|
|
except Exception as e:
|
|
import traceback
|
|
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
print(f"Error in /api/v1/agents/{agent_id}/profile: {error_detail}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.post(
|
|
"/api/v1/agents/{agent_id}/background",
|
|
response_model=BackgroundResponse,
|
|
tags=["Agent Management"],
|
|
summary="Add/merge agent background",
|
|
description="Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers personality traits.",
|
|
operation_id="add_agent_background"
|
|
)
|
|
async def api_add_agent_background(
|
|
agent_id: str,
|
|
request: AddBackgroundRequest
|
|
):
|
|
"""Add or merge agent background information. Optionally infer personality traits."""
|
|
try:
|
|
result = await app.state.memory.merge_agent_background(
|
|
agent_id,
|
|
request.content,
|
|
update_personality=request.update_personality
|
|
)
|
|
|
|
response = BackgroundResponse(background=result["background"])
|
|
if "personality" in result:
|
|
response.personality = PersonalityTraits(**result["personality"])
|
|
|
|
return response
|
|
except Exception as e:
|
|
import traceback
|
|
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
print(f"Error in /api/v1/agents/{agent_id}/background: {error_detail}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.put(
|
|
"/api/v1/agents/{agent_id}",
|
|
response_model=AgentProfileResponse,
|
|
tags=["Agent Management"],
|
|
summary="Create or update agent",
|
|
description="Create a new agent or update existing agent with personality and background. Auto-fills missing fields with defaults.",
|
|
operation_id="create_or_update_agent"
|
|
)
|
|
async def api_create_or_update_agent(
|
|
agent_id: str,
|
|
request: CreateAgentRequest
|
|
):
|
|
"""Create or update an agent with personality and background."""
|
|
try:
|
|
# Get existing profile or create with defaults
|
|
profile = await app.state.memory.get_agent_profile(agent_id)
|
|
|
|
# Update name if provided
|
|
if request.name is not None:
|
|
pool = await app.state.memory._get_pool()
|
|
async with acquire_with_retry(pool) as conn:
|
|
await conn.execute(
|
|
"""
|
|
UPDATE agents
|
|
SET name = $2,
|
|
updated_at = NOW()
|
|
WHERE agent_id = $1
|
|
""",
|
|
agent_id,
|
|
request.name
|
|
)
|
|
profile["name"] = request.name
|
|
|
|
# Update personality if provided
|
|
if request.personality is not None:
|
|
await app.state.memory.update_agent_personality(
|
|
agent_id,
|
|
request.personality.model_dump()
|
|
)
|
|
profile["personality"] = request.personality.model_dump()
|
|
|
|
# Update background if provided (replace, not merge)
|
|
if request.background is not None:
|
|
pool = await app.state.memory._get_pool()
|
|
async with acquire_with_retry(pool) as conn:
|
|
await conn.execute(
|
|
"""
|
|
UPDATE agents
|
|
SET background = $2,
|
|
updated_at = NOW()
|
|
WHERE agent_id = $1
|
|
""",
|
|
agent_id,
|
|
request.background
|
|
)
|
|
profile["background"] = request.background
|
|
|
|
# Get final profile
|
|
final_profile = await app.state.memory.get_agent_profile(agent_id)
|
|
return AgentProfileResponse(
|
|
agent_id=agent_id,
|
|
name=final_profile["name"],
|
|
personality=PersonalityTraits(**final_profile["personality"]),
|
|
background=final_profile["background"]
|
|
)
|
|
except Exception as e:
|
|
import traceback
|
|
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
print(f"Error in /api/v1/agents/{agent_id}: {error_detail}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@app.delete(
|
|
"/api/v1/agents/{agent_id}/memories",
|
|
response_model=DeleteResponse,
|
|
tags=["Agent Management"],
|
|
summary="Clear agent memories",
|
|
description="Delete memory units for an agent. Optionally filter by fact_type (world, agent, opinion) to delete only specific types. This is a destructive operation that cannot be undone. The agent profile (personality and background) will be preserved.",
|
|
operation_id="clear_agent_memories"
|
|
)
|
|
async def api_clear_agent_memories(
|
|
agent_id: str,
|
|
fact_type: Optional[str] = Query(None, description="Optional fact type filter (world, agent, opinion)")
|
|
):
|
|
"""Clear memories for an agent, optionally filtered by fact_type."""
|
|
try:
|
|
result = await app.state.memory.delete_agent(agent_id, fact_type=fact_type)
|
|
|
|
units_deleted = result.get('memory_units_deleted', 0)
|
|
entities_deleted = result.get('entities_deleted', 0)
|
|
|
|
if fact_type:
|
|
message = f"Cleared {units_deleted} {fact_type} memories for agent '{agent_id}'"
|
|
else:
|
|
message = f"Cleared all memories for agent '{agent_id}': {units_deleted} memory units, {entities_deleted} entities deleted"
|
|
|
|
return DeleteResponse(
|
|
success=True,
|
|
message=message
|
|
)
|
|
except Exception as e:
|
|
import traceback
|
|
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
|
print(f"Error in /api/v1/agents/{agent_id}/memories: {error_detail}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|