diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index d6aac4a..79aac37 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -34,19 +34,15 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.12] # Not testing with 3.13 at the moment - redis-version: ['6.2.6-v9', 'latest'] # 8.0-M03 is not working atm + python-version: [3.12] + redis-version: ['redis/redis-stack:6.2.6-v9', 'redis:8.0.3', 'redis:latest'] steps: - uses: actions/checkout@v3 - name: Set Redis image name run: | - if [[ "${{ matrix.redis-version }}" == "8.0-M03" ]]; then - echo "REDIS_IMAGE=redis:${{ matrix.redis-version }}" >> $GITHUB_ENV - else - echo "REDIS_IMAGE=redis/redis-stack-server:${{ matrix.redis-version }}" >> $GITHUB_ENV - fi + echo "REDIS_IMAGE=${{ matrix.redis-version }}" >> $GITHUB_ENV - name: Set up Python uses: actions/setup-python@v4 @@ -81,6 +77,12 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + - name: Log in to GitHub Container Registry uses: docker/login-action@v3 with: @@ -103,6 +105,8 @@ jobs: platforms: linux/amd64,linux/arm64 push: true tags: | + andrewbrookins510/agent-memory-server:latest + andrewbrookins510/agent-memory-server:${{ steps.version.outputs.version }} ghcr.io/${{ github.repository }}:latest ghcr.io/${{ github.repository }}:${{ steps.version.outputs.version }} cache-from: type=gha diff --git a/README.md b/README.md index 45e07fd..c8f0f69 100644 --- a/README.md +++ b/README.md @@ -7,24 +7,15 @@ A Redis-powered memory server built for AI agents and applications. It manages b - **Working Memory** - Session-scoped storage for messages, structured memories, context, and metadata - - Automatically summarizes conversations when they exceed a client-configured window size + - Automatically summarizes conversations when they exceed a client-configured (or server-managed) window size - Supports all major OpenAI and Anthropic models - Automatic (background) promotion of structured memories to long-term storage - **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 + - Pluggable Vector Store Backends - Support for any LangChain VectorStore (defaults to Redis) + - Semantic search to retrieve memories with advanced filtering - Filter by session, user ID, namespace, topics, entities, timestamps, and more - Supports both exact match and semantic similarity search - Automatic topic modeling for stored memories with BERTopic or configured LLM @@ -63,11 +54,7 @@ This project is under active development and is **pre-release** software. Think ### Roadmap -- [x] Long-term memory deduplication and compaction -- [x] Use a background task system instead of `BackgroundTask` -- [x] Authentication/authorization hooks (OAuth2/JWT support) -- [ ] Configurable strategy for moving working memory to long-term memory -- [ ] Separate Redis connections for long-term and working memory +- [] Easier RBAC customization: role definitions, more hooks ## REST API Endpoints diff --git a/agent-memory-client/agent_memory_client/__init__.py b/agent-memory-client/agent_memory_client/__init__.py index a47da80..8cee03a 100644 --- a/agent-memory-client/agent_memory_client/__init__.py +++ b/agent-memory-client/agent_memory_client/__init__.py @@ -5,7 +5,7 @@ memory management capabilities for AI agents and applications. """ -__version__ = "0.9.0" +__version__ = "0.9.1" from .client import MemoryAPIClient, MemoryClientConfig, create_memory_client from .exceptions import ( diff --git a/agent-memory-client/agent_memory_client/models.py b/agent-memory-client/agent_memory_client/models.py index 0529f16..bc731c9 100644 --- a/agent-memory-client/agent_memory_client/models.py +++ b/agent-memory-client/agent_memory_client/models.py @@ -183,8 +183,8 @@ class WorkingMemory(BaseModel): ) # TTL and timestamps - ttl_seconds: int = Field( - default=3600, # 1 hour default + ttl_seconds: int | None = Field( + default=None, # Persistent by default description="TTL for the working memory in seconds", ) last_accessed: datetime = Field( diff --git a/agent_memory_server/__init__.py b/agent_memory_server/__init__.py index 95f9e1f..b48f3c6 100644 --- a/agent_memory_server/__init__.py +++ b/agent_memory_server/__init__.py @@ -1,3 +1,3 @@ """Redis Agent Memory Server - A memory system for conversational AI.""" -__version__ = "0.9.0" +__version__ = "0.9.1" diff --git a/agent_memory_server/mcp.py b/agent_memory_server/mcp.py index ece561d..6ca1dc3 100644 --- a/agent_memory_server/mcp.py +++ b/agent_memory_server/mcp.py @@ -137,7 +137,14 @@ async def run_sse_async(self): redis = await get_redis_conn() await ensure_search_index_exists(redis) - return await super().run_sse_async() + + # Run the SSE server using our custom implementation + import uvicorn + + app = self.sse_app() + await uvicorn.Server( + uvicorn.Config(app, host="0.0.0.0", port=int(self.settings.port)) + ).serve() async def run_stdio_async(self): """Ensure Redis search index exists before starting STDIO MCP server.""" diff --git a/agent_memory_server/models.py b/agent_memory_server/models.py index 2aa8737..7fda47c 100644 --- a/agent_memory_server/models.py +++ b/agent_memory_server/models.py @@ -201,8 +201,8 @@ class WorkingMemory(BaseModel): ) # TTL and timestamps - ttl_seconds: int = Field( - default=3600, # 1 hour default + ttl_seconds: int | None = Field( + default=None, # Persistent by default description="TTL for the working memory in seconds", ) last_accessed: datetime = Field( diff --git a/agent_memory_server/working_memory.py b/agent_memory_server/working_memory.py index 9be6475..5326536 100644 --- a/agent_memory_server/working_memory.py +++ b/agent_memory_server/working_memory.py @@ -121,7 +121,7 @@ async def get_working_memory( tokens=working_memory_data.get("tokens", 0), session_id=session_id, namespace=namespace, - ttl_seconds=working_memory_data.get("ttl_seconds", 3600), + ttl_seconds=working_memory_data.get("ttl_seconds", None), data=working_memory_data.get("data") or {}, last_accessed=datetime.fromtimestamp( working_memory_data.get("last_accessed", int(time.time())), UTC @@ -188,18 +188,24 @@ async def set_working_memory( } try: - # Store with TTL - await redis_client.setex( - key, - working_memory.ttl_seconds, - json.dumps( - data, default=json_datetime_handler - ), # Add custom handler for any remaining datetime objects - ) - logger.info( - f"Set working memory for session {working_memory.session_id} with TTL {working_memory.ttl_seconds}s" - ) - + if working_memory.ttl_seconds is not None: + # Store with TTL + await redis_client.setex( + key, + working_memory.ttl_seconds, + json.dumps(data, default=json_datetime_handler), + ) + logger.info( + f"Set working memory for session {working_memory.session_id} with TTL {working_memory.ttl_seconds}s" + ) + else: + await redis_client.set( + key, + json.dumps(data, default=json_datetime_handler), + ) + logger.info( + f"Set working memory for session {working_memory.session_id} with no TTL" + ) except Exception as e: logger.error( f"Error setting working memory for session {working_memory.session_id}: {e}" diff --git a/docker-compose.yml b/docker-compose.yml index ef918c6..74cdbef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,7 +34,7 @@ services: dockerfile: Dockerfile environment: - REDIS_URL=redis://redis:6379 - - PORT=9000 + - PORT=9050 # Add your API keys here or use a .env file - OPENAI_API_KEY=${OPENAI_API_KEY} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} @@ -46,19 +46,41 @@ services: - ENABLE_TOPIC_EXTRACTION=True - ENABLE_NER=True ports: - - "9000:9000" + - "9050:9000" depends_on: - redis command: ["uv", "run", "agent-memory", "mcp", "--mode", "sse"] + task-worker: + build: + context: . + dockerfile: Dockerfile + environment: + - REDIS_URL=redis://redis:6379 + # Add your API keys here or use a .env file + - OPENAI_API_KEY=${OPENAI_API_KEY} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + # Optional configurations with defaults + - LONG_TERM_MEMORY=True + - WINDOW_SIZE=20 + - GENERATION_MODEL=gpt-4o-mini + - EMBEDDING_MODEL=text-embedding-3-small + - ENABLE_TOPIC_EXTRACTION=True + - ENABLE_NER=True + depends_on: + - redis + command: ["uv", "run", "agent-memory", "task-worker"] + volumes: + - ./agent_memory_server:/app/agent_memory_server + restart: unless-stopped + redis: - image: redis/redis-stack:latest + image: redis:8 ports: - - "16379:6379" # Redis port - - "18001:8001" # RedisInsight port + - "16380:6379" # Redis port volumes: - redis_data:/data - command: redis-stack-server --save 60 1 --loglevel warning + command: redis-server --save "" --loglevel warning --appendonly no --stop-writes-on-bgsave-error no healthcheck: test: [ "CMD", "redis-cli", "ping" ] interval: 30s diff --git a/examples/memory_prompt_agent.py b/examples/memory_prompt_agent.py index c3a3318..2aa665f 100644 --- a/examples/memory_prompt_agent.py +++ b/examples/memory_prompt_agent.py @@ -34,9 +34,13 @@ # Configure logging -logging.basicConfig(level=logging.INFO) +logging.basicConfig(level=logging.WARNING) logger = logging.getLogger(__name__) +# Reduce third-party logging +logging.getLogger("httpx").setLevel(logging.WARNING) +logging.getLogger("openai").setLevel(logging.WARNING) + # Environment setup MEMORY_SERVER_URL = os.getenv("MEMORY_SERVER_URL", "http://localhost:8000") DEFAULT_USER = "demo_user" @@ -96,7 +100,6 @@ async def cleanup(self): """Clean up resources.""" if self._memory_client: await self._memory_client.close() - logger.info("Memory client closed") async def _add_message_to_working_memory( self, session_id: str, user_id: str, role: str, content: str @@ -145,8 +148,6 @@ async def _generate_response( content = content["text"] messages.append({"role": msg["role"], "content": str(content)}) - logger.info(f"Total messages for LLM: {len(messages)}") - # Generate response response = self.llm.invoke(messages) return str(response.content) diff --git a/examples/travel_agent.py b/examples/travel_agent.py index 1aeaebc..52ba0ad 100644 --- a/examples/travel_agent.py +++ b/examples/travel_agent.py @@ -28,7 +28,6 @@ import json import logging import os -import textwrap from agent_memory_client import ( MemoryAPIClient, @@ -37,16 +36,26 @@ from agent_memory_client.models import ( WorkingMemory, ) -from langchain_community.tools.tavily_search import TavilySearchResults from langchain_core.callbacks.manager import CallbackManagerForToolRun from langchain_openai import ChatOpenAI from redis import Redis +try: + from langchain_community.tools.tavily_search import TavilySearchResults +except ImportError as e: + raise ImportError("Please install langchain-community for this demo.") from e + + # Configure logging -logging.basicConfig(level=logging.INFO) +logging.basicConfig(level=logging.WARNING) logger = logging.getLogger(__name__) +# Reduce third-party logging +logging.getLogger("httpx").setLevel(logging.WARNING) +logging.getLogger("openai").setLevel(logging.WARNING) + + # Environment setup MEMORY_SERVER_URL = os.getenv("MEMORY_SERVER_URL", "http://localhost:8000") REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379") @@ -56,35 +65,35 @@ SYSTEM_PROMPT = { "role": "system", - "content": textwrap.dedent(""" - You are a helpful travel assistant. You can help with travel-related questions. - You have access to conversation history and memory management tools to provide - personalized responses. - - Available tools: - - 1. **web_search** (if available): Search for current travel information, weather, - events, or other up-to-date data when specifically needed. - - 2. **Memory Management Tools** (always available): - - **search_memory**: Look up previous conversations and stored information - - **get_working_memory**: Check current session context - - **add_memory_to_working_memory**: Store important preferences or information - - **update_working_memory_data**: Save session-specific data - - **Guidelines**: - - Answer the user's actual question first and directly - - When someone shares information (like "I like X"), simply acknowledge it naturally - don't immediately give advice or suggestions unless they ask - - Search memory or web when it would be helpful for the current conversation - - Don't assume the user is actively planning a trip unless they explicitly say so - - Be conversational and natural - respond to what the user actually says - - When sharing memories, simply state what you remember rather than turning it into advice - - Only offer suggestions, recommendations, or tips if the user explicitly asks for them - - Store preferences and important details, but don't be overly eager about it - - If someone shares a preference, respond like a friend would - acknowledge it, maybe ask a follow-up question, but don't launch into advice - - Be helpful, friendly, and responsive. Mirror their conversational style - if they're just chatting, chat back. If they ask for help, then help. - """), + "content": """ + You are a helpful travel assistant. You can help with travel-related questions. + You have access to conversation history and memory management tools to provide + personalized responses. + + Available tools: + + 1. **web_search** (if available): Search for current travel information, weather, + events, or other up-to-date data when specifically needed. + + 2. **Memory Management Tools** (always available): + - **search_memory**: Look up previous conversations and stored information + - **get_working_memory**: Check current session context + - **add_memory_to_working_memory**: Store important preferences or information + - **update_working_memory_data**: Save session-specific data + + **Guidelines**: + - Answer the user's actual question first and directly + - When someone shares information (like "I like X"), simply acknowledge it naturally - don't immediately give advice or suggestions unless they ask + - Search memory or web when it would be helpful for the current conversation + - Don't assume the user is actively planning a trip unless they explicitly say so + - Be conversational and natural - respond to what the user actually says + - When sharing memories, simply state what you remember rather than turning it into advice + - Only offer suggestions, recommendations, or tips if the user explicitly asks for them + - Store preferences and important details, but don't be overly eager about it + - If someone shares a preference, respond like a friend would - acknowledge it, maybe ask a follow-up question, but don't launch into advice + + Be helpful, friendly, and responsive. Mirror their conversational style - if they're just chatting, chat back. If they ask for help, then help. + """, } @@ -151,12 +160,12 @@ def _setup_llms(self): # Define the web search tool function web_search_function = { "name": "web_search", - "description": textwrap.dedent(""" + "description": """ Search the web for current information about travel destinations, requirements, weather, events, or any other travel-related queries. Use this when you need up-to-date information that may not be in your training data. - """), + """, "parameters": { "type": "object", "properties": { diff --git a/manual_oauth_qa/README.md b/manual_oauth_qa/README.md index c7236b3..fa00d75 100644 --- a/manual_oauth_qa/README.md +++ b/manual_oauth_qa/README.md @@ -79,7 +79,7 @@ ANTHROPIC_API_KEY=your-actual-anthropic-key docker-compose up redis # Or using Docker directly -docker run -d -p 6379:6379 redis/redis-stack-server:latest +docker run -d -p 6379:6379 redis:8.0.3 ``` #### 3.2 Start Memory Server diff --git a/manual_oauth_qa/quick_auth0_setup.sh b/manual_oauth_qa/quick_auth0_setup.sh index c13b0fc..c367018 100755 --- a/manual_oauth_qa/quick_auth0_setup.sh +++ b/manual_oauth_qa/quick_auth0_setup.sh @@ -29,19 +29,19 @@ fi # Check if Redis is running echo "🔍 Checking Redis connection..." -if redis-cli ping > /dev/null 2>&1; then +if redis-cli ping >/dev/null 2>&1; then echo "✅ Redis is running" else echo "❌ Redis is not running. Starting Redis with Docker..." - if command -v docker > /dev/null 2>&1; then - docker run -d -p 6379:6379 --name redis-memory-test redis/redis-stack-server:latest + if command -v docker >/dev/null 2>&1; then + docker run -d -p 6379:6379 --name redis-memory-test redis:8.0.3 echo "✅ Started Redis container" sleep 2 else echo "❌ Docker not found. Please start Redis manually:" echo " brew install redis && brew services start redis" echo " OR" - echo " docker run -d -p 6379:6379 redis/redis-stack-server:latest" + echo " docker run -d -p 6379:6379 redis:8.0.3" exit 1 fi fi @@ -77,8 +77,8 @@ echo "🔍 Testing Auth0 token endpoint..." AUTH0_DOMAIN=$(echo $OAUTH2_ISSUER_URL | sed 's|https://||' | sed 's|/||') TOKEN_RESPONSE=$(curl -s -X POST "https://$AUTH0_DOMAIN/oauth/token" \ - -H "Content-Type: application/json" \ - -d "{ + -H "Content-Type: application/json" \ + -d "{ \"client_id\": \"$AUTH0_CLIENT_ID\", \"client_secret\": \"$AUTH0_CLIENT_SECRET\", \"audience\": \"$OAUTH2_AUDIENCE\", diff --git a/tests/conftest.py b/tests/conftest.py index 4c434f6..5d708be 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -219,7 +219,7 @@ def redis_container(request): # Set the Compose project name so containers do not clash across workers os.environ["COMPOSE_PROJECT_NAME"] = f"redis_test_{worker_id}" - os.environ.setdefault("REDIS_IMAGE", "redis/redis-stack-server:latest") + os.environ.setdefault("REDIS_IMAGE", "redis:8.0.3") current_dir = os.path.dirname(os.path.abspath(__file__)) diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index a0368a1..96f602e 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -1,6 +1,6 @@ services: redis: - image: "${REDIS_IMAGE:-redis/redis-stack-server:latest}" + image: "${REDIS_IMAGE:-redis:8.0.3}" ports: - "6379" environment: diff --git a/tests/test_working_memory.py b/tests/test_working_memory.py index bf736ab..f7db305 100644 --- a/tests/test_working_memory.py +++ b/tests/test_working_memory.py @@ -1,9 +1,12 @@ """Tests for working memory functionality.""" +import asyncio + import pytest from pydantic import ValidationError from agent_memory_server.models import MemoryRecord, MemoryTypeEnum, WorkingMemory +from agent_memory_server.utils.keys import Keys from agent_memory_server.working_memory import ( delete_working_memory, get_working_memory, @@ -60,6 +63,7 @@ async def test_set_and_get_working_memory(self, async_redis_client): assert retrieved_mem.memories[0].id == "client-1" assert retrieved_mem.memories[1].text == "User is working on a Python project" assert retrieved_mem.memories[1].id == "client-2" + assert retrieved_mem.ttl_seconds == 1800 # Verify TTL is preserved @pytest.mark.asyncio async def test_get_nonexistent_working_memory(self, async_redis_client): @@ -155,3 +159,183 @@ async def test_working_memory_validation(self, async_redis_client): assert retrieved is not None assert len(retrieved.memories) == 1 assert retrieved.memories[0].id == "test-memory-1" + + @pytest.mark.asyncio + async def test_working_memory_ttl_none(self, async_redis_client): + """Test working memory without TTL (persistent)""" + session_id = "test-session-no-ttl" + namespace = "test-namespace" + + memories = [ + MemoryRecord( + text="Persistent memory", + id="persistent-1", + memory_type=MemoryTypeEnum.SEMANTIC, + ), + ] + + working_mem = WorkingMemory( + memories=memories, + session_id=session_id, + namespace=namespace, + ttl_seconds=None, # No TTL - should be persistent + ) + + await set_working_memory(working_mem, redis_client=async_redis_client) + + # Get working memory and verify TTL is None + retrieved_mem = await get_working_memory( + session_id=session_id, + namespace=namespace, + redis_client=async_redis_client, + ) + + assert retrieved_mem is not None + assert retrieved_mem.ttl_seconds is None + + # Verify the Redis key has no TTL set (-1 means no TTL) + key = Keys.working_memory_key( + session_id=session_id, + namespace=namespace, + ) + ttl = await async_redis_client.ttl(key) + assert ttl == -1 # No TTL set + + @pytest.mark.asyncio + async def test_working_memory_ttl_set(self, async_redis_client): + """Test working memory with TTL set""" + session_id = "test-session-with-ttl" + namespace = "test-namespace" + + memories = [ + MemoryRecord( + text="Memory with TTL", + id="ttl-memory-1", + memory_type=MemoryTypeEnum.SEMANTIC, + ), + ] + + ttl_seconds = 60 # 1 minute + working_mem = WorkingMemory( + memories=memories, + session_id=session_id, + namespace=namespace, + ttl_seconds=ttl_seconds, + ) + + await set_working_memory(working_mem, redis_client=async_redis_client) + + # Get working memory and verify TTL is preserved + retrieved_mem = await get_working_memory( + session_id=session_id, + namespace=namespace, + redis_client=async_redis_client, + ) + + assert retrieved_mem is not None + assert retrieved_mem.ttl_seconds == ttl_seconds + + # Verify the Redis key has TTL set (should be <= 60 seconds) + key = Keys.working_memory_key( + session_id=session_id, + namespace=namespace, + ) + ttl = await async_redis_client.ttl(key) + assert 0 < ttl <= ttl_seconds + + @pytest.mark.asyncio + async def test_working_memory_ttl_expiration(self, async_redis_client): + """Test working memory expires after TTL""" + session_id = "test-session-expire" + namespace = "test-namespace" + + memories = [ + MemoryRecord( + text="Memory that expires", + id="expire-memory-1", + memory_type=MemoryTypeEnum.SEMANTIC, + ), + ] + + ttl_seconds = 1 # 1 second + working_mem = WorkingMemory( + memories=memories, + session_id=session_id, + namespace=namespace, + ttl_seconds=ttl_seconds, + ) + + await set_working_memory(working_mem, redis_client=async_redis_client) + + # Verify it exists immediately + retrieved_mem = await get_working_memory( + session_id=session_id, + namespace=namespace, + redis_client=async_redis_client, + ) + assert retrieved_mem is not None + + # Wait for TTL to expire + await asyncio.sleep(1.1) + + # Verify it's gone after TTL + retrieved_mem = await get_working_memory( + session_id=session_id, + namespace=namespace, + redis_client=async_redis_client, + ) + assert retrieved_mem is None + + @pytest.mark.asyncio + async def test_working_memory_ttl_update_preserves_ttl(self, async_redis_client): + """Test that updating working memory preserves TTL""" + session_id = "test-session-update-ttl" + namespace = "test-namespace" + + memories = [ + MemoryRecord( + text="Original memory", + id="original-memory-1", + memory_type=MemoryTypeEnum.SEMANTIC, + ), + ] + + ttl_seconds = 120 # 2 minutes + working_mem = WorkingMemory( + memories=memories, + session_id=session_id, + namespace=namespace, + ttl_seconds=ttl_seconds, + ) + + await set_working_memory(working_mem, redis_client=async_redis_client) + + # Update the working memory + working_mem.memories.append( + MemoryRecord( + text="Updated memory", + id="updated-memory-1", + memory_type=MemoryTypeEnum.SEMANTIC, + ) + ) + + await set_working_memory(working_mem, redis_client=async_redis_client) + + # Get updated working memory and verify TTL is preserved + retrieved_mem = await get_working_memory( + session_id=session_id, + namespace=namespace, + redis_client=async_redis_client, + ) + + assert retrieved_mem is not None + assert retrieved_mem.ttl_seconds == ttl_seconds + assert len(retrieved_mem.memories) == 2 + + # Verify the Redis key still has TTL set + key = Keys.working_memory_key( + session_id=session_id, + namespace=namespace, + ) + ttl = await async_redis_client.ttl(key) + assert 0 < ttl <= ttl_seconds