Skip to content

Add a long-term memory interface #25

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ docker-compose down # Stop all services
IMPORTANT: This project uses `pre-commit`. You should run `pre-commit`
before committing:
```bash
uv run pre-commit install # Install the hooks first
uv run pre-commit run --all-files
```

Expand All @@ -68,7 +69,7 @@ Working Memory (Session-scoped) → Long-term Memory (Persistent)
```python
# Correct - Use RedisVL queries
from redisvl.query import VectorQuery, FilterQuery
query = VectorQuery(vector=embedding, vector_field_name="embedding", return_fields=["text"])
query = VectorQuery(vector=embedding, vector_field_name="vector", return_fields=["text"])

# Avoid - Direct redis client searches
# redis.ft().search(...) # Don't do this
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ A Redis-powered memory server built for AI agents and applications. It manages b
- **Long-Term Memory**

- Persistent storage for memories across sessions
- **Pluggable Vector Store Backends** - Support for multiple vector databases through LangChain VectorStore interface:
- **Redis** (default) - RedisStack with RediSearch
- **Chroma** - Open-source vector database
- **Pinecone** - Managed vector database service
- **Weaviate** - Open-source vector search engine
- **Qdrant** - Vector similarity search engine
- **Milvus** - Cloud-native vector database
- **PostgreSQL/PGVector** - PostgreSQL with vector extensions
- **LanceDB** - Embedded vector database
- **OpenSearch** - Open-source search and analytics suite
- Semantic search to retrieve memories with advanced filtering system
- Filter by session, namespace, topics, entities, timestamps, and more
- Supports both exact match and semantic similarity search
Expand Down Expand Up @@ -84,6 +94,8 @@ Configure servers and workers using environment variables. Includes background t

For complete configuration details, see [Configuration Guide](docs/configuration.md).

For vector store backend options and setup, see [Vector Store Backends](docs/vector-store-backends.md).

## License

Apache 2.0 License - see [LICENSE](LICENSE) file for details.
Expand Down
4 changes: 2 additions & 2 deletions agent-memory-client/agent_memory_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
from typing_extensions import Self

import httpx
import ulid
from pydantic import BaseModel
from ulid import ULID

from .exceptions import MemoryClientError, MemoryServerError, MemoryValidationError
from .filters import (
Expand Down Expand Up @@ -466,7 +466,7 @@ async def add_memories_to_working_memory(
# Auto-generate IDs for memories that don't have them
for memory in final_memories:
if not memory.id:
memory.id = str(ulid.ULID())
memory.id = str(ULID())

# Create new working memory with the memories
working_memory = WorkingMemory(
Expand Down
4 changes: 2 additions & 2 deletions agent-memory-client/agent_memory_client/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
from enum import Enum
from typing import Any, Literal, TypedDict

import ulid
from pydantic import BaseModel, Field
from ulid import ULID

# Model name literals for model-specific window sizes
ModelNameLiteral = Literal[
Expand Down Expand Up @@ -122,7 +122,7 @@ class ClientMemoryRecord(MemoryRecord):
"""A memory record with a client-provided ID"""

id: str = Field(
default_factory=lambda: str(ulid.ULID()),
default_factory=lambda: str(ULID()),
description="Client-provided ID generated by the client (ULID)",
)

Expand Down
19 changes: 13 additions & 6 deletions agent_memory_server/api.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import tiktoken
import ulid
from fastapi import APIRouter, Depends, HTTPException
from mcp.server.fastmcp.prompts import base
from mcp.types import TextContent
from ulid import ULID

from agent_memory_server import long_term_memory, working_memory
from agent_memory_server.auth import UserInfo, get_current_user
Expand Down Expand Up @@ -344,7 +344,7 @@ async def put_working_memory(

memories = [
MemoryRecord(
id=str(ulid.ULID()),
id=str(ULID()),
session_id=session_id,
text=f"{msg.role}: {msg.content}",
namespace=updated_memory.namespace,
Expand Down Expand Up @@ -449,13 +449,10 @@ async def search_long_term_memory(
if not settings.long_term_memory:
raise HTTPException(status_code=400, detail="Long-term memory is disabled")

redis = await get_redis_conn()

# Extract filter objects from the payload
filters = payload.get_filters()

kwargs = {
"redis": redis,
"distance_threshold": payload.distance_threshold,
"limit": payload.limit,
"offset": payload.offset,
Expand All @@ -465,7 +462,7 @@ async def search_long_term_memory(
if payload.text:
kwargs["text"] = payload.text

# Pass text, redis, and filter objects to the search function
# Pass text and filter objects to the search function (no redis needed for vectorstore adapter)
return await long_term_memory.search_long_term_memories(**kwargs)


Expand Down Expand Up @@ -635,6 +632,16 @@ async def memory_prompt(
),
)
)
else:
# Always include a system message about long-term memories, even if empty
_messages.append(
SystemMessage(
content=TextContent(
type="text",
text="## Long term memories related to the user's query\n No relevant long-term memories found.",
),
)
)

_messages.append(
base.UserMessage(
Expand Down
120 changes: 104 additions & 16 deletions agent_memory_server/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import os
from typing import Literal
from typing import Any, Literal

import yaml
from dotenv import load_dotenv
Expand All @@ -9,12 +9,42 @@
load_dotenv()


def load_yaml_settings():
config_path = os.getenv("APP_CONFIG_FILE", "config.yaml")
if os.path.exists(config_path):
with open(config_path) as f:
return yaml.safe_load(f) or {}
return {}
# Model configuration mapping
MODEL_CONFIGS = {
"gpt-4o": {"provider": "openai", "embedding_dimensions": None},
"gpt-4o-mini": {"provider": "openai", "embedding_dimensions": None},
"gpt-4": {"provider": "openai", "embedding_dimensions": None},
"gpt-3.5-turbo": {"provider": "openai", "embedding_dimensions": None},
"text-embedding-3-small": {"provider": "openai", "embedding_dimensions": 1536},
"text-embedding-3-large": {"provider": "openai", "embedding_dimensions": 3072},
"text-embedding-ada-002": {"provider": "openai", "embedding_dimensions": 1536},
"claude-3-opus-20240229": {"provider": "anthropic", "embedding_dimensions": None},
"claude-3-sonnet-20240229": {"provider": "anthropic", "embedding_dimensions": None},
"claude-3-haiku-20240307": {"provider": "anthropic", "embedding_dimensions": None},
"claude-3-5-sonnet-20240620": {
"provider": "anthropic",
"embedding_dimensions": None,
},
"claude-3-5-sonnet-20241022": {
"provider": "anthropic",
"embedding_dimensions": None,
},
"claude-3-5-haiku-20241022": {
"provider": "anthropic",
"embedding_dimensions": None,
},
"claude-3-7-sonnet-20250219": {
"provider": "anthropic",
"embedding_dimensions": None,
},
"claude-3-7-sonnet-latest": {"provider": "anthropic", "embedding_dimensions": None},
"claude-3-5-sonnet-latest": {"provider": "anthropic", "embedding_dimensions": None},
"claude-3-5-haiku-latest": {"provider": "anthropic", "embedding_dimensions": None},
"claude-3-opus-latest": {"provider": "anthropic", "embedding_dimensions": None},
"o1": {"provider": "openai", "embedding_dimensions": None},
"o1-mini": {"provider": "openai", "embedding_dimensions": None},
"o3-mini": {"provider": "openai", "embedding_dimensions": None},
}


class Settings(BaseSettings):
Expand All @@ -27,16 +57,28 @@ class Settings(BaseSettings):
port: int = 8000
mcp_port: int = 9000

# Vector store factory configuration
# Python dotted path to function that returns VectorStore or VectorStoreAdapter
# Function signature: (embeddings: Embeddings) -> Union[VectorStore, VectorStoreAdapter]
# Examples:
# - "agent_memory_server.vectorstore_factory.create_redis_vectorstore"
# - "my_module.my_vectorstore_factory"
# - "my_package.adapters.create_custom_adapter"
vectorstore_factory: str = (
"agent_memory_server.vectorstore_factory.create_redis_vectorstore"
)

# RedisVL configuration (used by default Redis factory)
redisvl_index_name: str = "memory_records"

# The server indexes messages in long-term memory by default. If this
# setting is enabled, we also extract discrete memories from message text
# and save them as separate long-term memory records.
enable_discrete_memory_extraction: bool = True

# Topic modeling
topic_model_source: Literal["BERTopic", "LLM"] = "LLM"
topic_model: str = (
"MaartenGr/BERTopic_Wikipedia" # Use an LLM model name here if using LLM
)
topic_model: str = "gpt-4o-mini"
enable_topic_extraction: bool = True
top_k_topics: int = 3

Expand All @@ -45,10 +87,11 @@ class Settings(BaseSettings):
enable_ner: bool = True

# RedisVL Settings
# TODO: Adapt to vector store settings
redisvl_distance_metric: str = "COSINE"
redisvl_vector_dimensions: str = "1536"
redisvl_index_name: str = "memory"
redisvl_index_prefix: str = "memory"
redisvl_index_prefix: str = "memory_idx"
redisvl_indexing_algorithm: str = "HNSW"

# Docket settings
docket_name: str = "memory-server"
Expand All @@ -74,9 +117,54 @@ class Settings(BaseSettings):
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
extra = "ignore" # Ignore extra fields in YAML/env
extra = "ignore" # Ignore extra environment variables

@property
def generation_model_config(self) -> dict[str, Any]:
"""Get configuration for the generation model."""
return MODEL_CONFIGS.get(self.generation_model, {})

@property
def embedding_model_config(self) -> dict[str, Any]:
"""Get configuration for the embedding model."""
return MODEL_CONFIGS.get(self.embedding_model, {})

def load_yaml_config(self, config_path: str) -> dict[str, Any]:
"""Load configuration from YAML file."""
if not os.path.exists(config_path):
return {}
with open(config_path) as f:
return yaml.safe_load(f) or {}


settings = Settings()


def get_config():
"""Get configuration from environment and settings files."""
config_data = {}

# If REDIS_MEMORY_CONFIG is set, load config from file
config_file = os.getenv("REDIS_MEMORY_CONFIG")
if config_file:
try:
with open(config_file) as f:
if config_file.endswith((".yaml", ".yml")):
config_data = yaml.safe_load(f) or {}
else:
# Assume JSON
import json

config_data = json.load(f) or {}
except FileNotFoundError:
print(f"Warning: Config file {config_file} not found")
except Exception as e:
print(f"Warning: Error loading config file {config_file}: {e}")

# Environment variables override file config
for key, value in os.environ.items():
if key.startswith("REDIS_MEMORY_"):
config_key = key[13:].lower() # Remove REDIS_MEMORY_ prefix
config_data[config_key] = value

# Load YAML config first, then let env vars override
yaml_settings = load_yaml_settings()
settings = Settings(**yaml_settings)
return config_data
Loading