├── .env.example ├── .github └── workflows │ ├── claude.yml │ └── python-tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CLAUDE.md ├── Dockerfile ├── LICENSE ├── README.md ├── agent_memory_server ├── __init__.py ├── api.py ├── auth.py ├── cli.py ├── client │ ├── __init__.py │ └── api.py ├── config.py ├── dependencies.py ├── dev_server.py ├── docket_tasks.py ├── extraction.py ├── filters.py ├── healthcheck.py ├── llms.py ├── logging.py ├── long_term_memory.py ├── main.py ├── mcp.py ├── migrations.py ├── models.py ├── summarization.py ├── test_config.py ├── utils │ ├── __init__.py │ ├── api_keys.py │ ├── keys.py │ └── redis.py └── working_memory.py ├── claude.png ├── cursor.png ├── diagram.png ├── docker-compose.yml ├── docs ├── README.md ├── api.md ├── authentication.md ├── cli.md ├── configuration.md ├── development.md ├── getting-started.md ├── mcp.md └── memory-types.md ├── examples └── travel_agent.ipynb ├── manual_oauth_qa ├── README.md ├── TROUBLESHOOTING.md ├── debug_auth0.py ├── env_template ├── manual_auth0_test.py ├── quick_auth0_setup.sh ├── quick_setup.sh ├── setup_check.py └── test_auth0.py ├── poetry.lock ├── pyproject.toml ├── pytest.ini ├── tests ├── __init__.py ├── conftest.py ├── docker-compose.yml ├── test_api.py ├── test_auth.py ├── test_cli.py ├── test_client_api.py ├── test_client_enhancements.py ├── test_extraction.py ├── test_llms.py ├── test_long_term_memory.py ├── test_mcp.py ├── test_memory_compaction.py ├── test_models.py ├── test_summarization.py └── test_working_memory.py └── uv.lock /.env.example: -------------------------------------------------------------------------------- 1 | # Redis connection 2 | REDIS_URL=redis://localhost:6379 3 | 4 | # Server port 5 | PORT=8000 6 | 7 | # Memory settings 8 | LONG_TERM_MEMORY=true 9 | WINDOW_SIZE=12 10 | GENERATION_MODEL=gpt-4o-mini 11 | EMBEDDING_MODEL=text-embedding-3-small 12 | 13 | # OpenAI API key 14 | OPENAI_API_KEY=your_openai_api_key 15 | 16 | # OAuth2/JWT Authentication (for production) 17 | # OAUTH2_ISSUER_URL=https://your-auth-provider.com 18 | # OAUTH2_AUDIENCE=your-api-audience 19 | # OAUTH2_JWKS_URL=https://your-auth-provider.com/.well-known/jwks.json # Optional 20 | 21 | # Development Mode (DISABLE AUTHENTICATION - DEVELOPMENT ONLY) 22 | DISABLE_AUTH=true 23 | -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | issues: 9 | types: [opened, assigned] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | jobs: 14 | claude: 15 | if: | 16 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 17 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 19 | (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | pull-requests: write 24 | issues: write 25 | id-token: write 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | with: 30 | fetch-depth: 1 31 | 32 | - name: Run Claude Code 33 | id: claude 34 | uses: anthropics/claude-code-action@beta 35 | with: 36 | anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} 37 | 38 | # Define which tools Claude can use 39 | allowed_tools: "Bash,Bash(python:*),Bash(ruff:*),Bash(uv:*),Bash(pip:*),Bash(pytest:*),Bash(git status),Bash(git log),Bash(git show),Bash(git blame),Bash(git reflog),Bash(git stash list),Bash(git ls-files),Bash(git branch),Bash(git tag),Bash(git diff),Bash(make:*),Bash(pytest:*),Bash(cd:*),Bash(ls:*),Bash(make),Bash(make:*),View,GlobTool,GrepTool,BatchTool,Bash(cd /home/runner/work/agent-memory-server/agent-memory-server && ruff check),Bash(cd /home/runner/work/agent-memory-server/agent-memory-server && pytest*)" 40 | -------------------------------------------------------------------------------- /.github/workflows/python-tests.yml: -------------------------------------------------------------------------------- 1 | name: Python Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Set up Python 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: '3.12' 19 | cache: 'pip' 20 | 21 | - name: Install pre-commit 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install uv 25 | uv sync --only-dev 26 | 27 | - name: Run pre-commit 28 | run: | 29 | uv run pre-commit run --all-files 30 | 31 | test: 32 | needs: lint 33 | runs-on: ubuntu-latest 34 | strategy: 35 | fail-fast: false 36 | matrix: 37 | python-version: [3.12] # Not testing with 3.13 at the moment 38 | redis-version: ['6.2.6-v9', 'latest'] # 8.0-M03 is not working atm 39 | 40 | steps: 41 | - uses: actions/checkout@v3 42 | 43 | - name: Set Redis image name 44 | run: | 45 | if [[ "${{ matrix.redis-version }}" == "8.0-M03" ]]; then 46 | echo "REDIS_IMAGE=redis:${{ matrix.redis-version }}" >> $GITHUB_ENV 47 | else 48 | echo "REDIS_IMAGE=redis/redis-stack-server:${{ matrix.redis-version }}" >> $GITHUB_ENV 49 | fi 50 | 51 | - name: Set up Python 52 | uses: actions/setup-python@v4 53 | with: 54 | python-version: ${{ matrix.python-version }} 55 | cache: 'pip' 56 | 57 | - name: Install dependencies 58 | run: | 59 | python -m pip install --upgrade pip 60 | pip install uv 61 | uv sync --all-extras 62 | 63 | - name: Run tests 64 | run: | 65 | uv run pytest --run-api-tests 66 | env: 67 | OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/python,venv,macos 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,venv,macos 3 | 4 | ### macOS ### 5 | # General 6 | .DS_Store 7 | .AppleDouble 8 | .LSOverride 9 | 10 | # Icon must end with two \r 11 | Icon 12 | 13 | # Thumbnails 14 | ._* 15 | 16 | # Files that might appear in the root of a volume 17 | .DocumentRevisions-V100 18 | .fseventsd 19 | .Spotlight-V100 20 | .TemporaryItems 21 | .Trashes 22 | .VolumeIcon.icns 23 | .com.apple.timemachine.donotpresent 24 | 25 | # Directories potentially created on remote AFP share 26 | .AppleDB 27 | .AppleDesktop 28 | Network Trash Folder 29 | Temporary Items 30 | .apdisk 31 | 32 | ### macOS Patch ### 33 | # iCloud generated files 34 | *.icloud 35 | 36 | ### Python ### 37 | # Byte-compiled / optimized / DLL files 38 | __pycache__/ 39 | *.py[cod] 40 | *$py.class 41 | 42 | # C extensions 43 | *.so 44 | 45 | # Distribution / packaging 46 | .Python 47 | build/ 48 | develop-eggs/ 49 | dist/ 50 | downloads/ 51 | eggs/ 52 | .eggs/ 53 | lib/ 54 | lib64/ 55 | parts/ 56 | sdist/ 57 | var/ 58 | wheels/ 59 | share/python-wheels/ 60 | *.egg-info/ 61 | .installed.cfg 62 | *.egg 63 | MANIFEST 64 | 65 | # PyInstaller 66 | # Usually these files are written by a python script from a template 67 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 68 | *.manifest 69 | *.spec 70 | 71 | # Installer logs 72 | pip-log.txt 73 | pip-delete-this-directory.txt 74 | 75 | # Unit test / coverage reports 76 | htmlcov/ 77 | .tox/ 78 | .nox/ 79 | .coverage 80 | .coverage.* 81 | .cache 82 | nosetests.xml 83 | coverage.xml 84 | *.cover 85 | *.py,cover 86 | .hypothesis/ 87 | .pytest_cache/ 88 | cover/ 89 | 90 | # Translations 91 | *.mo 92 | *.pot 93 | 94 | # Django stuff: 95 | *.log 96 | local_settings.py 97 | db.sqlite3 98 | db.sqlite3-journal 99 | 100 | # Flask stuff: 101 | instance/ 102 | .webassets-cache 103 | 104 | # Scrapy stuff: 105 | .scrapy 106 | 107 | # Sphinx documentation 108 | docs/_build/ 109 | 110 | # PyBuilder 111 | .pybuilder/ 112 | target/ 113 | 114 | # Jupyter Notebook 115 | .ipynb_checkpoints 116 | 117 | # IPython 118 | profile_default/ 119 | ipython_config.py 120 | 121 | # pyenv 122 | # For a library or package, you might want to ignore these files since the code is 123 | # intended to run in multiple environments; otherwise, check them in: 124 | .python-version 125 | 126 | # pipenv 127 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 128 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 129 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 130 | # install all needed dependencies. 131 | #Pipfile.lock 132 | 133 | # poetry 134 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 135 | # This is especially recommended for binary packages to ensure reproducibility, and is more 136 | # commonly ignored for libraries. 137 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 138 | #poetry.lock 139 | 140 | # pdm 141 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 142 | #pdm.lock 143 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 144 | # in version control. 145 | # https://pdm.fming.dev/#use-with-ide 146 | .pdm.toml 147 | 148 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 149 | __pypackages__/ 150 | 151 | # Celery stuff 152 | celerybeat-schedule 153 | celerybeat.pid 154 | 155 | # SageMath parsed files 156 | *.sage.py 157 | 158 | # Environments 159 | .env 160 | .venv 161 | env/ 162 | venv/ 163 | ENV/ 164 | env.bak/ 165 | venv.bak/ 166 | .venv/ 167 | 168 | # Spyder project settings 169 | .spyderproject 170 | .spyproject 171 | 172 | # Rope project settings 173 | .ropeproject 174 | 175 | # mkdocs documentation 176 | /site 177 | 178 | # mypy 179 | .mypy_cache/ 180 | .dmypy.json 181 | dmypy.json 182 | 183 | # Pyre type checker 184 | .pyre/ 185 | 186 | # pytype static type analyzer 187 | .pytype/ 188 | 189 | # Cython debug symbols 190 | cython_debug/ 191 | 192 | # PyCharm 193 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 194 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 195 | # and can be added to the global gitignore or merged into this file. For a more nuclear 196 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 197 | #.idea/ 198 | 199 | ### Python Patch ### 200 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 201 | poetry.toml 202 | 203 | # ruff 204 | .ruff_cache/ 205 | 206 | # LSP config files 207 | pyrightconfig.json 208 | 209 | # Security: Exclude files that may contain secrets 210 | .env-old 211 | auth0-test-config.env 212 | *.env.backup 213 | *.env.local 214 | *-config.env 215 | 216 | ### venv ### 217 | # Virtualenv 218 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 219 | [Bb]in 220 | [Ii]nclude 221 | [Ll]ib 222 | [Ll]ib64 223 | [Ll]ocal 224 | pyvenv.cfg 225 | pip-selfcheck.json 226 | 227 | libs/redis/docs/.Trash* 228 | .python-version 229 | .idea/* 230 | .vscode/settings.json 231 | .cursor 232 | 233 | *.pyc 234 | ai 235 | .claude 236 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.3.2 # Use the latest version 4 | hooks: 5 | # Run the linter 6 | - id: ruff 7 | args: [--fix, --unsafe-fixes] 8 | # Run the formatter 9 | - id: ruff-format 10 | 11 | - repo: https://github.com/pre-commit/pre-commit-hooks 12 | rev: v4.5.0 13 | hooks: 14 | - id: trailing-whitespace 15 | - id: end-of-file-fixer 16 | - id: check-yaml 17 | - id: check-added-large-files 18 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md - Redis Agent Memory Server Project Context 2 | 3 | ## Frequently Used Commands 4 | Get started in a new environment by installing `uv`: 5 | ```bash 6 | pip install uv 7 | ``` 8 | 9 | ```bash 10 | # Development workflow 11 | uv install # Install dependencies 12 | uv run ruff check # Run linting 13 | uv run ruff format # Format code 14 | uv run pytest # Run tests 15 | uv run pytest tests/ # Run specific test directory 16 | 17 | # Server commands 18 | uv run agent-memory api # Start REST API server (default port 8000) 19 | uv run agent-memory mcp # Start MCP server (stdio mode) 20 | uv run agent-memory mcp --mode sse --port 9000 # Start MCP server (SSE mode) 21 | 22 | # Database/Redis operations 23 | uv run agent-memory rebuild-index # Rebuild Redis search index 24 | uv run agent-memory migrate-memories # Run memory migrations 25 | 26 | # Background task management 27 | uv run agent-memory task-worker # Start background task worker 28 | uv run agent-memory schedule-task "agent_memory_server.long_term_memory.compact_long_term_memories" 29 | 30 | # Docker development 31 | docker-compose up # Start full stack (API, MCP, Redis) 32 | docker-compose up redis # Start only Redis Stack 33 | docker-compose down # Stop all services 34 | ``` 35 | 36 | IMPORTANT: This project uses `pre-commit`. You should run `pre-commit` 37 | before committing: 38 | ```bash 39 | uv run pre-commit run --all-files 40 | ``` 41 | 42 | ## Important Architectural Patterns 43 | 44 | ### Dual Interface Design (REST + MCP) 45 | - **REST API**: Traditional HTTP endpoints for web applications (`api.py`) 46 | - **MCP Server**: Model Context Protocol for AI agent integration (`mcp.py`) 47 | - Both interfaces share the same core memory management logic 48 | 49 | ### Memory Architecture 50 | ```python 51 | # Two-tier memory system 52 | Working Memory (Session-scoped) → Long-term Memory (Persistent) 53 | ↓ ↓ 54 | - Messages - Semantic search 55 | - Context - Topic modeling 56 | - Structured memories - Entity recognition 57 | - Metadata - Deduplication 58 | ``` 59 | 60 | ### RedisVL Integration 61 | **CRITICAL**: Always use RedisVL query types instead of direct redis-py client access for searches: 62 | ```python 63 | # Correct - Use RedisVL queries 64 | from redisvl.query import VectorQuery, FilterQuery 65 | query = VectorQuery(vector=embedding, vector_field_name="embedding", return_fields=["text"]) 66 | 67 | # Avoid - Direct redis client searches 68 | # redis.ft().search(...) # Don't do this 69 | ``` 70 | 71 | ### Async-First Design 72 | - All core operations are async 73 | - Background task processing with Docket 74 | - Async Redis connections throughout 75 | 76 | ## Critical Rules 77 | 78 | ### Authentication 79 | - **PRODUCTION**: Never set `DISABLE_AUTH=true` in production 80 | - **DEVELOPMENT**: Use `DISABLE_AUTH=true` for local testing only 81 | - JWT/OAuth2 authentication required for all endpoints except `/health`, `/docs`, `/openapi.json` 82 | 83 | ### Memory Management 84 | - Working memory automatically promotes structured memories to long-term storage 85 | - Conversations are summarized when exceeding window size 86 | - Use model-aware token limits for context window management 87 | 88 | ### RedisVL Usage (Required) 89 | Always use RedisVL query types for any search operations. This is a project requirement. 90 | 91 | ## Testing Notes 92 | 93 | The project uses `pytest` with `testcontainers` for Redis integration testing: 94 | 95 | - `uv run pytest` - Run all tests 96 | - `uv run pytest tests/unit/` - Unit tests only 97 | - `uv run pytest tests/integration/` - Integration tests (require Redis) 98 | - `uv run pytest -v` - Verbose output 99 | - `uv run pytest --cov` - With coverage 100 | 101 | ## Project Structure 102 | 103 | ``` 104 | agent_memory_server/ 105 | ├── main.py # FastAPI application entry point 106 | ├── api.py # REST API endpoints 107 | ├── mcp.py # MCP server implementation 108 | ├── config.py # Configuration management 109 | ├── auth.py # OAuth2/JWT authentication 110 | ├── models.py # Pydantic data models 111 | ├── working_memory.py # Session-scoped memory management 112 | ├── long_term_memory.py # Persistent memory with semantic search 113 | ├── messages.py # Message handling and formatting 114 | ├── summarization.py # Conversation summarization 115 | ├── extraction.py # Topic and entity extraction 116 | ├── filters.py # Search filtering logic 117 | ├── llms.py # LLM provider integrations 118 | ├── migrations.py # Database schema migrations 119 | ├── docket_tasks.py # Background task definitions 120 | ├── cli.py # Command-line interface 121 | ├── dependencies.py # FastAPI dependency injection 122 | ├── healthcheck.py # Health check endpoint 123 | ├── logging.py # Structured logging setup 124 | ├── client/ # Client libraries 125 | └── utils/ # Utility modules 126 | ├── redis.py # Redis connection and setup 127 | ├── keys.py # Redis key management 128 | └── api_keys.py # API key utilities 129 | ``` 130 | 131 | ## Core Components 132 | 133 | ### 1. Memory Management 134 | - **Working Memory**: Session-scoped storage with automatic summarization 135 | - **Long-term Memory**: Persistent storage with semantic search capabilities 136 | - **Memory Promotion**: Automatic migration from working to long-term memory 137 | - **Deduplication**: Prevents duplicate memories using content hashing 138 | 139 | ### 2. Search and Retrieval 140 | - **Semantic Search**: Vector-based similarity search using embeddings 141 | - **Filtering System**: Advanced filtering by session, namespace, topics, entities, timestamps 142 | - **Hybrid Search**: Combines semantic similarity with metadata filtering 143 | - **RedisVL Integration**: All search operations use RedisVL query builders 144 | 145 | ### 3. AI Integration 146 | - **Topic Modeling**: Automatic topic extraction using BERTopic or LLM 147 | - **Entity Recognition**: BERT-based named entity recognition 148 | - **Summarization**: Conversation summarization when context window exceeded 149 | - **Multi-LLM Support**: OpenAI, Anthropic, and other providers 150 | 151 | ### 4. Authentication & Security 152 | - **OAuth2/JWT**: Industry-standard authentication with JWKS validation 153 | - **Multi-Provider**: Auth0, AWS Cognito, Okta, Azure AD support 154 | - **Role-Based Access**: Fine-grained permissions using JWT claims 155 | - **Development Mode**: `DISABLE_AUTH` for local development 156 | 157 | ### 5. Background Processing 158 | - **Docket Tasks**: Redis-based task queue for background operations 159 | - **Memory Indexing**: Asynchronous embedding generation and indexing 160 | - **Compaction**: Periodic cleanup and optimization of stored memories 161 | 162 | ## Environment Configuration 163 | 164 | Key environment variables: 165 | ```bash 166 | # Redis 167 | REDIS_URL=redis://localhost:6379 168 | 169 | # Authentication (Production) 170 | OAUTH2_ISSUER_URL=https://your-auth-provider.com 171 | OAUTH2_AUDIENCE=your-api-audience 172 | DISABLE_AUTH=false # Never true in production 173 | 174 | # Development 175 | DISABLE_AUTH=true # Local development only 176 | LOG_LEVEL=DEBUG 177 | 178 | # AI Services 179 | OPENAI_API_KEY=your-key 180 | ANTHROPIC_API_KEY=your-key 181 | GENERATION_MODEL=gpt-4o-mini 182 | EMBEDDING_MODEL=text-embedding-3-small 183 | 184 | # Memory Configuration 185 | LONG_TERM_MEMORY=true 186 | WINDOW_SIZE=20 187 | ENABLE_TOPIC_EXTRACTION=true 188 | ENABLE_NER=true 189 | ``` 190 | 191 | ## API Interfaces 192 | 193 | ### REST API (Port 8000) 194 | - Session management (`/v1/working-memory/`) 195 | - Working memory operations (`/v1/working-memory/{id}`) 196 | - Long-term memory search (`/v1/long-term-memory/search`) 197 | - Memory hydration (`/v1/memory/prompt`) 198 | 199 | ### MCP Server (Port 9000) 200 | - `create_long_term_memories` - Store persistent memories 201 | - `search_long_term_memory` - Semantic search with filtering 202 | - `memory_prompt` - Hydrate queries with relevant context 203 | - `set_working_memory` - Manage session memory 204 | 205 | ## Development Workflow 206 | 207 | 0. **Install uv**: `pip install uv` to get started with uv 208 | 1. **Setup**: `uv install` to install dependencies 209 | 2. **Redis**: Start Redis Stack via `docker-compose up redis` 210 | 3. **Development**: Use `DISABLE_AUTH=true` for local testing 211 | 4. **Testing**: Run `uv run pytest` before committing 212 | 5. **Linting**: Pre-commit hooks handle code formatting 213 | 6. **Background Tasks**: Start worker with `uv run agent-memory task-worker` 214 | 215 | ## Documentation 216 | - API docs available at `/docs` when server is running 217 | - OpenAPI spec at `/openapi.json` 218 | - Authentication examples in README.md 219 | - System architecture diagram in `diagram.png` 220 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim 2 | 3 | WORKDIR /app 4 | 5 | ENV UV_COMPILE_BYTECODE=1 6 | ENV UV_LINK_MODE=copy 7 | 8 | # Install system dependencies including build tools 9 | RUN apt-get update && apt-get install -y \ 10 | curl \ 11 | build-essential \ 12 | gcc \ 13 | g++ \ 14 | && rm -rf /var/lib/apt/lists/* 15 | 16 | RUN --mount=type=cache,target=/root/.cache/uv \ 17 | --mount=type=bind,source=uv.lock,target=uv.lock \ 18 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ 19 | uv sync --frozen --no-install-project --no-dev 20 | 21 | ADD . /app 22 | RUN --mount=type=cache,target=/root/.cache/uv \ 23 | uv sync --frozen --no-dev 24 | 25 | ENV PATH="/app/.venv/bin:$PATH" 26 | 27 | ENTRYPOINT [] 28 | 29 | 30 | # Run the API server 31 | CMD ["uv", "run", "agent-memory", "api"] 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🔮 Redis Agent Memory Server 2 | 3 | A Redis-powered memory server built for AI agents and applications. It manages both conversational context and long-term memories, offering semantic search, automatic summarization, and flexible APIs through both REST and MCP interfaces. 4 | 5 | ## Features 6 | 7 | - **Working Memory** 8 | 9 | - Session-scoped storage for messages, structured memories, context, and metadata 10 | - Automatically summarizes conversations when they exceed the window size 11 | - Client model-aware token limit management (adapts to the context window of the client's LLM) 12 | - Supports all major OpenAI and Anthropic models 13 | - Automatic promotion of structured memories to long-term storage 14 | 15 | - **Long-Term Memory** 16 | 17 | - Persistent storage for memories across sessions 18 | - Semantic search to retrieve memories with advanced filtering system 19 | - Filter by session, namespace, topics, entities, timestamps, and more 20 | - Supports both exact match and semantic similarity search 21 | - Automatic topic modeling for stored memories with BERTopic or configured LLM 22 | - Automatic Entity Recognition using BERT 23 | - Memory deduplication and compaction 24 | 25 | - **Other Features** 26 | - Namespace support for session and working memory isolation 27 | - Both a REST interface and MCP server 28 | - Background task processing for memory indexing and promotion 29 | - Unified search across working memory and long-term memory 30 | 31 | For detailed information about memory types, their differences, and when to use each, see the [Memory Types Guide](docs/memory-types.md). 32 | 33 | ## Authentication 34 | 35 | The Redis Agent Memory Server supports OAuth2/JWT Bearer token authentication for secure API access. It's compatible with Auth0, AWS Cognito, Okta, Azure AD, and other standard OAuth2 providers. 36 | 37 | For complete authentication setup, configuration, and usage examples, see [Authentication Documentation](docs/authentication.md). 38 | 39 | For manual Auth0 testing, see the [manual OAuth testing guide](manual_oauth_qa/README.md). 40 | 41 | ## System Diagram 42 | 43 | ![System Diagram](diagram.png) 44 | 45 | ## Project Status and Roadmap 46 | 47 | ### Project Status: In Development, Pre-Release 48 | 49 | This project is under active development and is **pre-release** software. Think of it as an early beta! 50 | 51 | ### Roadmap 52 | 53 | - [x] Long-term memory deduplication and compaction 54 | - [x] Use a background task system instead of `BackgroundTask` 55 | - [x] Authentication/authorization hooks (OAuth2/JWT support) 56 | - [ ] Configurable strategy for moving working memory to long-term memory 57 | - [ ] Separate Redis connections for long-term and working memory 58 | 59 | ## REST API Endpoints 60 | 61 | The server provides REST endpoints for managing working memory, long-term memory, and memory search. Key endpoints include session management, memory storage/retrieval, semantic search, and memory-enriched prompts. 62 | 63 | For complete API documentation with examples, see [REST API Documentation](docs/api.md). 64 | 65 | ## MCP Server Interface 66 | 67 | Agent Memory Server offers an MCP (Model Context Protocol) server interface powered by FastMCP, providing tool-based memory management for LLMs and agents. Includes tools for working memory, long-term memory, semantic search, and memory-enriched prompts. 68 | 69 | For complete MCP setup and usage examples, see [MCP Documentation](docs/mcp.md). 70 | 71 | ## Command Line Interface 72 | 73 | The `agent-memory-server` provides a comprehensive CLI for managing servers and tasks. Key commands include starting API/MCP servers, scheduling background tasks, running workers, and managing migrations. 74 | 75 | For complete CLI documentation and examples, see [CLI Documentation](docs/cli.md). 76 | 77 | ## Getting Started 78 | 79 | For complete setup instructions, see [Getting Started Guide](docs/getting-started.md). 80 | 81 | ## Configuration 82 | 83 | Configure servers and workers using environment variables. Includes background task management, memory compaction, and data migrations. 84 | 85 | For complete configuration details, see [Configuration Guide](docs/configuration.md). 86 | 87 | ## Development 88 | 89 | For development setup, testing, and contributing guidelines, see [Development Guide](docs/development.md). 90 | -------------------------------------------------------------------------------- /agent_memory_server/__init__.py: -------------------------------------------------------------------------------- 1 | """Redis Agent Memory Server - A memory system for conversational AI.""" 2 | 3 | __version__ = "0.1.0" 4 | -------------------------------------------------------------------------------- /agent_memory_server/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Command-line interface for agent-memory-server. 4 | """ 5 | 6 | import datetime 7 | import importlib 8 | import logging 9 | import sys 10 | 11 | import click 12 | import uvicorn 13 | 14 | from agent_memory_server.config import settings 15 | from agent_memory_server.logging import configure_logging, get_logger 16 | from agent_memory_server.migrations import ( 17 | migrate_add_discrete_memory_extracted_2, 18 | migrate_add_memory_hashes_1, 19 | migrate_add_memory_type_3, 20 | ) 21 | from agent_memory_server.utils.redis import ensure_search_index_exists, get_redis_conn 22 | 23 | 24 | configure_logging() 25 | logger = get_logger(__name__) 26 | 27 | VERSION = "0.2.0" 28 | 29 | 30 | @click.group() 31 | def cli(): 32 | """Command-line interface for agent-memory-server.""" 33 | pass 34 | 35 | 36 | @cli.command() 37 | def version(): 38 | """Show the version of agent-memory-server.""" 39 | click.echo(f"agent-memory-server version {VERSION}") 40 | 41 | 42 | @cli.command() 43 | def rebuild_index(): 44 | """Rebuild the search index.""" 45 | import asyncio 46 | 47 | async def setup_and_run(): 48 | redis = await get_redis_conn() 49 | await ensure_search_index_exists(redis, overwrite=True) 50 | 51 | asyncio.run(setup_and_run()) 52 | 53 | 54 | @cli.command() 55 | def migrate_memories(): 56 | """Migrate memories from the old format to the new format.""" 57 | import asyncio 58 | 59 | click.echo("Starting memory migrations...") 60 | 61 | async def run_migrations(): 62 | redis = await get_redis_conn() 63 | migrations = [ 64 | migrate_add_memory_hashes_1, 65 | migrate_add_discrete_memory_extracted_2, 66 | migrate_add_memory_type_3, 67 | ] 68 | for migration in migrations: 69 | await migration(redis=redis) 70 | 71 | asyncio.run(run_migrations()) 72 | 73 | click.echo("Memory migrations completed successfully.") 74 | 75 | 76 | @cli.command() 77 | @click.option("--port", default=settings.port, help="Port to run the server on") 78 | @click.option("--host", default="0.0.0.0", help="Host to run the server on") 79 | @click.option("--reload", is_flag=True, help="Enable auto-reload") 80 | def api(port: int, host: str, reload: bool): 81 | """Run the REST API server.""" 82 | from agent_memory_server.main import on_start_logger 83 | 84 | on_start_logger(port) 85 | uvicorn.run( 86 | "agent_memory_server.main:app", 87 | host=host, 88 | port=port, 89 | reload=reload, 90 | ) 91 | 92 | 93 | @cli.command() 94 | @click.option("--port", default=settings.mcp_port, help="Port to run the MCP server on") 95 | @click.option( 96 | "--mode", 97 | default="stdio", 98 | help="Run the MCP server in SSE or stdio mode", 99 | type=click.Choice(["stdio", "sse"]), 100 | ) 101 | def mcp(port: int, mode: str): 102 | """Run the MCP server.""" 103 | import asyncio 104 | 105 | # Update the port in settings FIRST 106 | settings.mcp_port = port 107 | 108 | # Import mcp_app AFTER settings have been updated 109 | from agent_memory_server.mcp import mcp_app 110 | 111 | async def setup_and_run(): 112 | # Redis setup is handled by the MCP app before it starts 113 | 114 | # Run the MCP server 115 | if mode == "sse": 116 | logger.info(f"Starting MCP server on port {port}\n") 117 | await mcp_app.run_sse_async() 118 | elif mode == "stdio": 119 | # Try to force all logging to stderr because stdio-mode MCP servers 120 | # use standard output for the protocol. 121 | logging.basicConfig( 122 | level=settings.log_level, 123 | stream=sys.stderr, 124 | force=True, # remove any existing handlers 125 | format="%(asctime)s %(name)s %(levelname)s %(message)s", 126 | ) 127 | await mcp_app.run_stdio_async() 128 | else: 129 | raise ValueError(f"Invalid mode: {mode}") 130 | 131 | # Update the port in settings 132 | settings.mcp_port = port 133 | 134 | asyncio.run(setup_and_run()) 135 | 136 | 137 | @cli.command() 138 | @click.argument("task_path") 139 | @click.option( 140 | "--args", 141 | "-a", 142 | multiple=True, 143 | help="Arguments to pass to the task in the format key=value", 144 | ) 145 | def schedule_task(task_path: str, args: list[str]): 146 | """ 147 | Schedule a background task by path. 148 | 149 | TASK_PATH is the import path to the task function, e.g., 150 | "agent_memory_server.long_term_memory.compact_long_term_memories" 151 | """ 152 | import asyncio 153 | 154 | from docket import Docket 155 | 156 | # Parse the arguments 157 | task_args = {} 158 | for arg in args: 159 | try: 160 | key, value = arg.split("=", 1) 161 | # Try to convert to appropriate type 162 | if value.lower() == "true": 163 | task_args[key] = True 164 | elif value.lower() == "false": 165 | task_args[key] = False 166 | elif value.isdigit(): 167 | task_args[key] = int(value) 168 | elif value.replace(".", "", 1).isdigit() and value.count(".") <= 1: 169 | task_args[key] = float(value) 170 | else: 171 | task_args[key] = value 172 | except ValueError: 173 | click.echo(f"Invalid argument format: {arg}. Use key=value format.") 174 | sys.exit(1) 175 | 176 | async def setup_and_run_task(): 177 | redis = await get_redis_conn() 178 | await ensure_search_index_exists(redis) 179 | 180 | # Import the task function 181 | module_path, function_name = task_path.rsplit(".", 1) 182 | try: 183 | module = importlib.import_module(module_path) 184 | task_func = getattr(module, function_name) 185 | except (ImportError, AttributeError) as e: 186 | click.echo(f"Error importing task: {e}") 187 | sys.exit(1) 188 | 189 | # Initialize Docket client 190 | async with Docket( 191 | name=settings.docket_name, 192 | url=settings.redis_url, 193 | ) as docket: 194 | click.echo(f"Scheduling task {task_path} with arguments: {task_args}") 195 | await docket.add(task_func)(**task_args) 196 | click.echo("Task scheduled successfully") 197 | 198 | asyncio.run(setup_and_run_task()) 199 | 200 | 201 | @cli.command() 202 | @click.option( 203 | "--concurrency", default=10, help="Number of tasks to process concurrently" 204 | ) 205 | @click.option( 206 | "--redelivery-timeout", 207 | default=30, 208 | help="Seconds to wait before redelivering a task to another worker", 209 | ) 210 | def task_worker(concurrency: int, redelivery_timeout: int): 211 | """ 212 | Start a Docket worker using the Docket name from settings. 213 | 214 | This command starts a worker that processes background tasks registered 215 | with Docket. The worker uses the Docket name from settings. 216 | """ 217 | import asyncio 218 | 219 | from docket import Worker 220 | 221 | if not settings.use_docket: 222 | click.echo("Docket is disabled in settings. Cannot run worker.") 223 | sys.exit(1) 224 | 225 | asyncio.run( 226 | Worker.run( 227 | docket_name=settings.docket_name, 228 | url=settings.redis_url, 229 | concurrency=concurrency, 230 | redelivery_timeout=datetime.timedelta(seconds=redelivery_timeout), 231 | tasks=["agent_memory_server.docket_tasks:task_collection"], 232 | ) 233 | ) 234 | 235 | 236 | if __name__ == "__main__": 237 | cli() 238 | -------------------------------------------------------------------------------- /agent_memory_server/client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/agent-memory-server/6d4236e45089b3ceb5762fbbe93bc090e4746985/agent_memory_server/client/__init__.py -------------------------------------------------------------------------------- /agent_memory_server/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Literal 3 | 4 | import yaml 5 | from dotenv import load_dotenv 6 | from pydantic_settings import BaseSettings 7 | 8 | 9 | load_dotenv() 10 | 11 | 12 | def load_yaml_settings(): 13 | config_path = os.getenv("APP_CONFIG_FILE", "config.yaml") 14 | if os.path.exists(config_path): 15 | with open(config_path) as f: 16 | return yaml.safe_load(f) or {} 17 | return {} 18 | 19 | 20 | class Settings(BaseSettings): 21 | redis_url: str = "redis://localhost:6379" 22 | long_term_memory: bool = True 23 | window_size: int = 20 24 | openai_api_key: str | None = None 25 | anthropic_api_key: str | None = None 26 | generation_model: str = "gpt-4o-mini" 27 | embedding_model: str = "text-embedding-3-small" 28 | port: int = 8000 29 | mcp_port: int = 9000 30 | 31 | # The server indexes messages in long-term memory by default. If this 32 | # setting is enabled, we also extract discrete memories from message text 33 | # and save them as separate long-term memory records. 34 | enable_discrete_memory_extraction: bool = True 35 | 36 | # Topic modeling 37 | topic_model_source: Literal["BERTopic", "LLM"] = "LLM" 38 | topic_model: str = ( 39 | "MaartenGr/BERTopic_Wikipedia" # Use an LLM model name here if using LLM 40 | ) 41 | enable_topic_extraction: bool = True 42 | top_k_topics: int = 3 43 | 44 | # Used for extracting entities from text 45 | ner_model: str = "dbmdz/bert-large-cased-finetuned-conll03-english" 46 | enable_ner: bool = True 47 | 48 | # RedisVL Settings 49 | redisvl_distance_metric: str = "COSINE" 50 | redisvl_vector_dimensions: str = "1536" 51 | redisvl_index_name: str = "memory" 52 | redisvl_index_prefix: str = "memory" 53 | 54 | # Docket settings 55 | docket_name: str = "memory-server" 56 | use_docket: bool = True 57 | 58 | # OAuth2/JWT Authentication settings 59 | disable_auth: bool = False 60 | oauth2_issuer_url: str | None = None 61 | oauth2_audience: str | None = None 62 | oauth2_jwks_url: str | None = None 63 | oauth2_algorithms: list[str] = ["RS256"] 64 | 65 | # Auth0 Client Credentials (for testing and client applications) 66 | auth0_client_id: str | None = None 67 | auth0_client_secret: str | None = None 68 | 69 | # Other Application settings 70 | log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO" 71 | 72 | class Config: 73 | env_file = ".env" 74 | env_file_encoding = "utf-8" 75 | 76 | 77 | # Load YAML config first, then let env vars override 78 | yaml_settings = load_yaml_settings() 79 | settings = Settings(**yaml_settings) 80 | -------------------------------------------------------------------------------- /agent_memory_server/dependencies.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from typing import Any 3 | 4 | from fastapi import BackgroundTasks 5 | 6 | from agent_memory_server.config import settings 7 | from agent_memory_server.logging import get_logger 8 | 9 | 10 | logger = get_logger(__name__) 11 | 12 | 13 | class DocketBackgroundTasks(BackgroundTasks): 14 | """A BackgroundTasks implementation that uses Docket.""" 15 | 16 | async def add_task( 17 | self, func: Callable[..., Any], *args: Any, **kwargs: Any 18 | ) -> None: 19 | """Run tasks either directly or through Docket""" 20 | from docket import Docket 21 | 22 | from agent_memory_server.utils.redis import get_redis_conn 23 | 24 | logger.info("Adding task to background tasks...") 25 | 26 | if settings.use_docket: 27 | logger.info("Scheduling task through Docket") 28 | # Get the Redis connection that's already configured (will use testcontainer in tests) 29 | redis_conn = await get_redis_conn() 30 | # Use the connection's URL instead of settings.redis_url directly 31 | redis_url = redis_conn.connection_pool.connection_kwargs.get( 32 | "url", settings.redis_url 33 | ) 34 | logger.info("redis_url: %s", redis_url) 35 | logger.info("docket_name: %s", settings.docket_name) 36 | async with Docket( 37 | name=settings.docket_name, 38 | url=redis_url, 39 | ) as docket: 40 | # Schedule task through Docket 41 | await docket.add(func)(*args, **kwargs) 42 | else: 43 | logger.info("Running task directly") 44 | await func(*args, **kwargs) 45 | 46 | 47 | def get_background_tasks() -> DocketBackgroundTasks: 48 | """ 49 | Dependency function that returns a DocketBackgroundTasks instance. 50 | 51 | This is used by API endpoints to inject a consistent background tasks object. 52 | """ 53 | logger.info("Getting background tasks class") 54 | return DocketBackgroundTasks() 55 | -------------------------------------------------------------------------------- /agent_memory_server/dev_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Run the Redis Agent Memory Server.""" 3 | 4 | import os 5 | 6 | import uvicorn 7 | 8 | from agent_memory_server.main import on_start_logger 9 | 10 | 11 | if __name__ == "__main__": 12 | port = int(os.environ.get("PORT", "8000")) 13 | on_start_logger(port) 14 | uvicorn.run("agent_memory_server.main:app", host="0.0.0.0", port=port, reload=True) 15 | -------------------------------------------------------------------------------- /agent_memory_server/docket_tasks.py: -------------------------------------------------------------------------------- 1 | """ 2 | Background task management using Docket. 3 | """ 4 | 5 | import logging 6 | 7 | from docket import Docket 8 | 9 | from agent_memory_server.config import settings 10 | from agent_memory_server.extraction import extract_discrete_memories 11 | from agent_memory_server.long_term_memory import ( 12 | compact_long_term_memories, 13 | extract_memory_structure, 14 | index_long_term_memories, 15 | promote_working_memory_to_long_term, 16 | ) 17 | from agent_memory_server.summarization import summarize_session 18 | 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | # Register functions in the task collection for the CLI worker 24 | task_collection = [ 25 | extract_memory_structure, 26 | summarize_session, 27 | index_long_term_memories, 28 | compact_long_term_memories, 29 | extract_discrete_memories, 30 | promote_working_memory_to_long_term, 31 | ] 32 | 33 | 34 | async def register_tasks() -> None: 35 | """Register all task functions with Docket.""" 36 | if not settings.use_docket: 37 | logger.info("Docket is disabled, skipping task registration") 38 | return 39 | 40 | # Initialize Docket client 41 | async with Docket( 42 | name=settings.docket_name, 43 | url=settings.redis_url, 44 | ) as docket: 45 | # Register all tasks 46 | for task in task_collection: 47 | docket.register(task) 48 | 49 | logger.info(f"Registered {len(task_collection)} background tasks with Docket") 50 | -------------------------------------------------------------------------------- /agent_memory_server/filters.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum 3 | from typing import Self 4 | 5 | from pydantic import BaseModel 6 | from pydantic.functional_validators import model_validator 7 | from redisvl.query.filter import FilterExpression, Num, Tag 8 | 9 | 10 | class TagFilter(BaseModel): 11 | field: str 12 | eq: str | None = None 13 | ne: str | None = None 14 | any: list[str] | None = None 15 | all: list[str] | None = None 16 | 17 | @model_validator(mode="after") 18 | def validate_filters(self) -> Self: 19 | if self.eq is not None and self.ne is not None: 20 | raise ValueError("eq and ne cannot both be set") 21 | if self.any is not None and self.all is not None: 22 | raise ValueError("any and all cannot both be set") 23 | if self.all is not None and len(self.all) == 0: 24 | raise ValueError("all cannot be an empty list") 25 | if self.any is not None and len(self.any) == 0: 26 | raise ValueError("any cannot be an empty list") 27 | return self 28 | 29 | def to_filter(self) -> FilterExpression: 30 | if self.eq is not None: 31 | return Tag(self.field) == self.eq 32 | if self.ne is not None: 33 | return Tag(self.field) != self.ne 34 | if self.any is not None: 35 | return Tag(self.field) == self.any 36 | if self.all is not None: 37 | return Tag(self.field) == self.all 38 | raise ValueError("No filter provided") 39 | 40 | 41 | class EnumFilter(BaseModel): 42 | """Filter for enum fields - accepts enum values and validates them""" 43 | 44 | field: str 45 | enum_class: type[Enum] 46 | eq: str | None = None 47 | ne: str | None = None 48 | any: list[str] | None = None 49 | all: list[str] | None = None 50 | 51 | @model_validator(mode="after") 52 | def validate_filters(self) -> Self: 53 | if self.eq is not None and self.ne is not None: 54 | raise ValueError("eq and ne cannot both be set") 55 | if self.any is not None and self.all is not None: 56 | raise ValueError("any and all cannot both be set") 57 | if self.all is not None and len(self.all) == 0: 58 | raise ValueError("all cannot be an empty list") 59 | if self.any is not None and len(self.any) == 0: 60 | raise ValueError("any cannot be an empty list") 61 | 62 | # Validate enum values 63 | valid_values = [e.value for e in self.enum_class] 64 | 65 | if self.eq is not None and self.eq not in valid_values: 66 | raise ValueError( 67 | f"eq value '{self.eq}' not in valid enum values: {valid_values}" 68 | ) 69 | if self.ne is not None and self.ne not in valid_values: 70 | raise ValueError( 71 | f"ne value '{self.ne}' not in valid enum values: {valid_values}" 72 | ) 73 | if self.any is not None: 74 | for val in self.any: 75 | if val not in valid_values: 76 | raise ValueError( 77 | f"any value '{val}' not in valid enum values: {valid_values}" 78 | ) 79 | if self.all is not None: 80 | for val in self.all: 81 | if val not in valid_values: 82 | raise ValueError( 83 | f"all value '{val}' not in valid enum values: {valid_values}" 84 | ) 85 | 86 | return self 87 | 88 | def to_filter(self) -> FilterExpression: 89 | if self.eq is not None: 90 | return Tag(self.field) == self.eq 91 | if self.ne is not None: 92 | return Tag(self.field) != self.ne 93 | if self.any is not None: 94 | return Tag(self.field) == self.any 95 | if self.all is not None: 96 | return Tag(self.field) == self.all 97 | raise ValueError("No filter provided") 98 | 99 | 100 | class NumFilter(BaseModel): 101 | field: str 102 | gt: int | None = None 103 | lt: int | None = None 104 | gte: int | None = None 105 | lte: int | None = None 106 | eq: int | None = None 107 | ne: int | None = None 108 | between: list[float] | None = None 109 | inclusive: str = "both" 110 | 111 | @model_validator(mode="after") 112 | def validate_filters(self) -> Self: 113 | if self.between is not None and len(self.between) != 2: 114 | raise ValueError("between must be a list of two numbers") 115 | if self.between is not None and self.eq is not None: 116 | raise ValueError("between and eq cannot both be set") 117 | if self.between is not None and self.ne is not None: 118 | raise ValueError("between and ne cannot both be set") 119 | if self.between is not None and self.gt is not None: 120 | raise ValueError("between and gt cannot both be set") 121 | if self.between is not None and self.lt is not None: 122 | raise ValueError("between and lt cannot both be set") 123 | if self.between is not None and self.gte is not None: 124 | raise ValueError("between and gte cannot both be set") 125 | return self 126 | 127 | def to_filter(self) -> FilterExpression: 128 | if self.between is not None: 129 | return Num(self.field).between( 130 | int(self.between[0]), int(self.between[1]), self.inclusive 131 | ) 132 | if self.eq is not None: 133 | return Num(self.field) == self.eq 134 | if self.ne is not None: 135 | return Num(self.field) != self.ne 136 | if self.gt is not None: 137 | return Num(self.field) > self.gt 138 | if self.lt is not None: 139 | return Num(self.field) < self.lt 140 | if self.gte is not None: 141 | return Num(self.field) >= self.gte 142 | if self.lte is not None: 143 | return Num(self.field) <= self.lte 144 | raise ValueError("No filter provided") 145 | 146 | 147 | class DateTimeFilter(BaseModel): 148 | """Filter for datetime fields - accepts datetime objects and converts to timestamps for Redis queries""" 149 | 150 | field: str 151 | gt: datetime | None = None 152 | lt: datetime | None = None 153 | gte: datetime | None = None 154 | lte: datetime | None = None 155 | eq: datetime | None = None 156 | ne: datetime | None = None 157 | between: list[datetime] | None = None 158 | inclusive: str = "both" 159 | 160 | @model_validator(mode="after") 161 | def validate_filters(self) -> Self: 162 | if self.between is not None and len(self.between) != 2: 163 | raise ValueError("between must be a list of two datetimes") 164 | if self.between is not None and self.eq is not None: 165 | raise ValueError("between and eq cannot both be set") 166 | if self.between is not None and self.ne is not None: 167 | raise ValueError("between and ne cannot both be set") 168 | if self.between is not None and self.gt is not None: 169 | raise ValueError("between and gt cannot both be set") 170 | if self.between is not None and self.lt is not None: 171 | raise ValueError("between and lt cannot both be set") 172 | if self.between is not None and self.gte is not None: 173 | raise ValueError("between and gte cannot both be set") 174 | return self 175 | 176 | def to_filter(self) -> FilterExpression: 177 | """Convert datetime objects to timestamps for Redis numerical queries""" 178 | if self.between is not None: 179 | return Num(self.field).between( 180 | int(self.between[0].timestamp()), 181 | int(self.between[1].timestamp()), 182 | self.inclusive, 183 | ) 184 | if self.eq is not None: 185 | return Num(self.field) == int(self.eq.timestamp()) 186 | if self.ne is not None: 187 | return Num(self.field) != int(self.ne.timestamp()) 188 | if self.gt is not None: 189 | return Num(self.field) > int(self.gt.timestamp()) 190 | if self.lt is not None: 191 | return Num(self.field) < int(self.lt.timestamp()) 192 | if self.gte is not None: 193 | return Num(self.field) >= int(self.gte.timestamp()) 194 | if self.lte is not None: 195 | return Num(self.field) <= int(self.lte.timestamp()) 196 | raise ValueError("No filter provided") 197 | 198 | 199 | class SessionId(TagFilter): 200 | field: str = "session_id" 201 | 202 | 203 | class UserId(TagFilter): 204 | field: str = "user_id" 205 | 206 | 207 | class Namespace(TagFilter): 208 | field: str = "namespace" 209 | 210 | 211 | class CreatedAt(DateTimeFilter): 212 | field: str = "created_at" 213 | 214 | 215 | class LastAccessed(DateTimeFilter): 216 | field: str = "last_accessed" 217 | 218 | 219 | class Topics(TagFilter): 220 | field: str = "topics" 221 | 222 | 223 | class Entities(TagFilter): 224 | field: str = "entities" 225 | 226 | 227 | class MemoryType(EnumFilter): 228 | field: str = "memory_type" 229 | enum_class: type[Enum] | None = None # Will be set in __init__ 230 | 231 | def __init__(self, **data): 232 | # Import here to avoid circular imports 233 | from agent_memory_server.models import MemoryTypeEnum 234 | 235 | data["enum_class"] = MemoryTypeEnum 236 | super().__init__(**data) 237 | 238 | 239 | class EventDate(DateTimeFilter): 240 | field: str = "event_date" 241 | -------------------------------------------------------------------------------- /agent_memory_server/healthcheck.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from fastapi import APIRouter 4 | 5 | from agent_memory_server.models import HealthCheckResponse 6 | 7 | 8 | router = APIRouter() 9 | 10 | 11 | @router.get("/v1/health", response_model=HealthCheckResponse) 12 | async def get_health(): 13 | """ 14 | Health check endpoint 15 | 16 | Returns: 17 | HealthCheckResponse with current timestamp 18 | """ 19 | # Return current time in milliseconds 20 | return HealthCheckResponse(now=int(time.time() * 1000)) 21 | -------------------------------------------------------------------------------- /agent_memory_server/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | import structlog 5 | 6 | from agent_memory_server.config import settings 7 | 8 | 9 | _configured = False 10 | 11 | 12 | def configure_logging(): 13 | """Configure structured logging for the application""" 14 | global _configured 15 | if _configured: 16 | return 17 | 18 | # Configure standard library logging based on settings.log_level 19 | level = getattr(logging, settings.log_level.upper(), logging.INFO) 20 | handler = logging.StreamHandler(sys.stdout) 21 | handler.setLevel(level) 22 | logging.basicConfig(level=level, handlers=[handler], format="%(message)s") 23 | 24 | # Configure structlog with processors honoring the log level and structured output 25 | structlog.configure( 26 | processors=[ 27 | structlog.stdlib.filter_by_level, 28 | structlog.stdlib.add_logger_name, 29 | structlog.stdlib.add_log_level, 30 | structlog.processors.TimeStamper(fmt="iso"), 31 | structlog.processors.format_exc_info, 32 | structlog.processors.JSONRenderer(), 33 | ], 34 | wrapper_class=structlog.stdlib.BoundLogger, 35 | logger_factory=structlog.stdlib.LoggerFactory(), 36 | cache_logger_on_first_use=True, 37 | ) 38 | _configured = True 39 | 40 | 41 | def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger: 42 | """ 43 | Get a configured logger instance. 44 | 45 | Args: 46 | name: Optional name for the logger (usually __name__) 47 | 48 | Returns: 49 | A configured logger instance 50 | """ 51 | return structlog.get_logger(name) 52 | -------------------------------------------------------------------------------- /agent_memory_server/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from contextlib import asynccontextmanager 4 | 5 | import uvicorn 6 | from fastapi import FastAPI 7 | 8 | from agent_memory_server.api import router as memory_router 9 | from agent_memory_server.auth import verify_auth_config 10 | from agent_memory_server.config import settings 11 | from agent_memory_server.docket_tasks import register_tasks 12 | from agent_memory_server.healthcheck import router as health_router 13 | from agent_memory_server.llms import MODEL_CONFIGS, ModelProvider 14 | from agent_memory_server.logging import get_logger 15 | from agent_memory_server.utils.redis import ( 16 | _redis_pool as connection_pool, 17 | ensure_search_index_exists, 18 | get_redis_conn, 19 | ) 20 | 21 | 22 | logger = get_logger(__name__) 23 | 24 | 25 | @asynccontextmanager 26 | async def lifespan(app: FastAPI): 27 | """Initialize the application on startup""" 28 | logger.info("Starting Redis Agent Memory Server 🤘") 29 | 30 | # Verify OAuth2/JWT authentication configuration 31 | try: 32 | verify_auth_config() 33 | except Exception as e: 34 | logger.error(f"Authentication configuration error: {e}") 35 | raise 36 | 37 | # Check for required API keys 38 | available_providers = [] 39 | 40 | if settings.openai_api_key: 41 | available_providers.append(ModelProvider.OPENAI) 42 | else: 43 | logger.warning("OpenAI API key not set, OpenAI models will not be available") 44 | 45 | if settings.anthropic_api_key: 46 | available_providers.append(ModelProvider.ANTHROPIC) 47 | else: 48 | logger.warning( 49 | "Anthropic API key not set, Anthropic models will not be available" 50 | ) 51 | 52 | # Check if the configured models are available 53 | generation_model_config = MODEL_CONFIGS.get(settings.generation_model) 54 | embedding_model_config = MODEL_CONFIGS.get(settings.embedding_model) 55 | 56 | if ( 57 | generation_model_config 58 | and generation_model_config.provider not in available_providers 59 | ): 60 | logger.warning( 61 | f"Selected generation model {settings.generation_model} requires {generation_model_config.provider} API key" 62 | ) 63 | 64 | if ( 65 | embedding_model_config 66 | and embedding_model_config.provider not in available_providers 67 | ): 68 | logger.warning( 69 | f"Selected embedding model {settings.embedding_model} requires {embedding_model_config.provider} API key" 70 | ) 71 | 72 | # If long-term memory is enabled but OpenAI isn't available, warn user 73 | if settings.long_term_memory and ModelProvider.OPENAI not in available_providers: 74 | logger.warning( 75 | "Long-term memory requires OpenAI for embeddings, but OpenAI API key is not set" 76 | ) 77 | 78 | # Set up RediSearch index if long-term memory is enabled 79 | if settings.long_term_memory: 80 | redis = await get_redis_conn() 81 | 82 | # Get embedding dimensions from model config 83 | embedding_model_config = MODEL_CONFIGS.get(settings.embedding_model) 84 | vector_dimensions = ( 85 | str(embedding_model_config.embedding_dimensions) 86 | if embedding_model_config 87 | else "1536" 88 | ) 89 | distance_metric = "COSINE" 90 | 91 | try: 92 | await ensure_search_index_exists( 93 | redis, 94 | index_name=settings.redisvl_index_name, 95 | vector_dimensions=vector_dimensions, 96 | distance_metric=distance_metric, 97 | ) 98 | except Exception as e: 99 | logger.error(f"Failed to ensure RediSearch index: {e}") 100 | raise 101 | 102 | # Initialize Docket for background tasks if enabled 103 | if settings.use_docket: 104 | try: 105 | await register_tasks() 106 | logger.info("Initialized Docket for background tasks") 107 | logger.info("To run the worker, use one of these methods:") 108 | logger.info( 109 | "1. CLI: docket worker --tasks agent_memory_server.docket_tasks:task_collection" 110 | ) 111 | logger.info("2. Python: python -m agent_memory_server.worker") 112 | except Exception as e: 113 | logger.error(f"Failed to initialize Docket: {e}") 114 | raise 115 | 116 | # Show available models 117 | openai_models = [ 118 | model 119 | for model, config in MODEL_CONFIGS.items() 120 | if config.provider == ModelProvider.OPENAI 121 | and ModelProvider.OPENAI in available_providers 122 | ] 123 | anthropic_models = [ 124 | model 125 | for model, config in MODEL_CONFIGS.items() 126 | if config.provider == ModelProvider.ANTHROPIC 127 | and ModelProvider.ANTHROPIC in available_providers 128 | ] 129 | 130 | if openai_models: 131 | logger.info(f"Available OpenAI models: {', '.join(openai_models)}") 132 | if anthropic_models: 133 | logger.info(f"Available Anthropic models: {', '.join(anthropic_models)}") 134 | 135 | logger.info( 136 | "Redis Agent Memory Server initialized", 137 | window_size=settings.window_size, 138 | generation_model=settings.generation_model, 139 | embedding_model=settings.embedding_model, 140 | long_term_memory=settings.long_term_memory, 141 | ) 142 | 143 | yield 144 | 145 | logger.info("Shutting down Redis Agent Memory Server") 146 | if connection_pool is not None: 147 | await connection_pool.aclose() 148 | 149 | 150 | # Create FastAPI app 151 | app = FastAPI(title="Redis Agent Memory Server", lifespan=lifespan) 152 | 153 | 154 | app.include_router(health_router) 155 | app.include_router(memory_router) 156 | 157 | 158 | def on_start_logger(port: int): 159 | """Log startup information""" 160 | print("\n-----------------------------------") 161 | print(f"🧠 Redis Agent Memory Server running on port: {port}") 162 | print("-----------------------------------\n") 163 | 164 | 165 | # Run the application 166 | if __name__ == "__main__": 167 | # Parse command line arguments for port 168 | port = settings.port 169 | 170 | # Check if --port argument is provided 171 | if "--port" in sys.argv: 172 | try: 173 | port_index = sys.argv.index("--port") + 1 174 | if port_index < len(sys.argv): 175 | port = int(sys.argv[port_index]) 176 | print(f"Using port from command line: {port}") 177 | except (ValueError, IndexError): 178 | # If conversion fails or index out of bounds, use default 179 | print(f"Invalid port argument, using default: {port}") 180 | else: 181 | print(f"No port argument provided, using default: {port}") 182 | 183 | # Explicitly unset the PORT environment variable if it exists 184 | if "PORT" in os.environ: 185 | port_val = os.environ.pop("PORT") 186 | print(f"Removed environment variable PORT={port_val}") 187 | 188 | on_start_logger(port) 189 | uvicorn.run( 190 | app, # Using the app instance directly 191 | host="0.0.0.0", 192 | port=port, 193 | reload=False, 194 | ) 195 | -------------------------------------------------------------------------------- /agent_memory_server/migrations.py: -------------------------------------------------------------------------------- 1 | """ 2 | Simplest possible migrations you could have. 3 | """ 4 | 5 | from redis.asyncio import Redis 6 | from ulid import ULID 7 | 8 | from agent_memory_server.logging import get_logger 9 | from agent_memory_server.long_term_memory import generate_memory_hash 10 | from agent_memory_server.utils.keys import Keys 11 | from agent_memory_server.utils.redis import get_redis_conn 12 | 13 | 14 | logger = get_logger(__name__) 15 | 16 | 17 | async def migrate_add_memory_hashes_1(redis: Redis | None = None) -> None: 18 | """ 19 | Migration 1: Add memory_hash to all existing memories in Redis 20 | """ 21 | logger.info("Starting memory hash migration") 22 | redis = redis or await get_redis_conn() 23 | 24 | # 1. Scan Redis for all memory keys 25 | memory_keys = [] 26 | cursor = 0 27 | 28 | pattern = Keys.memory_key("*") 29 | 30 | while True: 31 | cursor, keys = await redis.scan(cursor=cursor, match=pattern, count=100) 32 | memory_keys.extend(keys) 33 | if cursor == 0: 34 | break 35 | 36 | if not memory_keys: 37 | logger.info("No memories found to migrate") 38 | return 39 | 40 | # 2. Process memories in batches 41 | batch_size = 50 42 | migrated_count = 0 43 | 44 | for i in range(0, len(memory_keys), batch_size): 45 | batch_keys = memory_keys[i : i + batch_size] 46 | pipeline = redis.pipeline() 47 | 48 | # First get the data 49 | for key in batch_keys: 50 | pipeline.hgetall(key) 51 | 52 | results = await pipeline.execute() 53 | 54 | # Now update with hashes 55 | update_pipeline = redis.pipeline() 56 | for j, result in enumerate(results): 57 | if not result: 58 | continue 59 | 60 | # Convert bytes to strings 61 | try: 62 | memory = { 63 | k.decode() if isinstance(k, bytes) else k: v.decode() 64 | if isinstance(v, bytes) 65 | else v 66 | for k, v in result.items() 67 | if k in (b"text", b"user_id", b"session_id", b"memory_hash") 68 | } 69 | except Exception as e: 70 | logger.error(f"Error decoding memory: {result}, {e}") 71 | continue 72 | 73 | if not memory or "memory_hash" in memory: 74 | continue 75 | 76 | memory_hash = generate_memory_hash(memory) 77 | 78 | update_pipeline.hset(batch_keys[j], "memory_hash", memory_hash) 79 | migrated_count += 1 80 | 81 | await update_pipeline.execute() 82 | logger.info(f"Migrated {migrated_count} memories so far") 83 | 84 | logger.info(f"Migration completed. Added hashes to {migrated_count} memories") 85 | 86 | 87 | async def migrate_add_discrete_memory_extracted_2(redis: Redis | None = None) -> None: 88 | """ 89 | Migration 2: Add discrete_memory_extracted to all existing memories in Redis 90 | """ 91 | logger.info("Starting discrete_memory_extracted migration") 92 | redis = redis or await get_redis_conn() 93 | 94 | keys = await redis.keys(Keys.memory_key("*")) 95 | 96 | migrated_count = 0 97 | for key in keys: 98 | id_ = await redis.hget(name=key, key="id_") # type: ignore 99 | if not id_: 100 | logger.info("Updating memory with no ID to set ID") 101 | await redis.hset(name=key, key="id_", value=str(ULID())) # type: ignore 102 | # extracted: bytes | None = await redis.hget( 103 | # name=key, key="discrete_memory_extracted" 104 | # ) # type: ignore 105 | # if extracted and extracted.decode() == "t": 106 | # continue 107 | await redis.hset(name=key, key="discrete_memory_extracted", value="f") # type: ignore 108 | migrated_count += 1 109 | 110 | logger.info( 111 | f"Migration completed. Added discrete_memory_extracted (f) to {migrated_count} memories" 112 | ) 113 | 114 | 115 | async def migrate_add_memory_type_3(redis: Redis | None = None) -> None: 116 | """ 117 | Migration 3: Add memory_type to all existing memories in Redis 118 | """ 119 | logger.info("Starting memory_type migration") 120 | redis = redis or await get_redis_conn() 121 | 122 | keys = await redis.keys(Keys.memory_key("*")) 123 | 124 | migrated_count = 0 125 | for key in keys: 126 | id_ = await redis.hget(name=key, key="id_") # type: ignore 127 | if not id_: 128 | logger.info("Updating memory with no ID to set ID") 129 | await redis.hset(name=key, key="id_", value=str(ULID())) # type: ignore 130 | memory_type: bytes | None = await redis.hget(name=key, key="memory_type") # type: ignore 131 | if not memory_type: 132 | await redis.hset(name=key, key="memory_type", value="message") # type: ignore 133 | migrated_count += 1 134 | 135 | logger.info(f"Migration completed. Added memory_type to {migrated_count} memories") 136 | -------------------------------------------------------------------------------- /agent_memory_server/summarization.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | import tiktoken 5 | from redis import WatchError 6 | 7 | from agent_memory_server.config import settings 8 | from agent_memory_server.llms import ( 9 | AnthropicClientWrapper, 10 | OpenAIClientWrapper, 11 | get_model_client, 12 | get_model_config, 13 | ) 14 | from agent_memory_server.models import MemoryMessage 15 | from agent_memory_server.utils.keys import Keys 16 | from agent_memory_server.utils.redis import get_redis_conn 17 | 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | async def _incremental_summary( 23 | model: str, 24 | client: OpenAIClientWrapper | AnthropicClientWrapper, 25 | context: str | None, 26 | messages: list[str], 27 | ) -> tuple[str, int]: 28 | """ 29 | Incrementally summarize messages, building upon a previous summary. 30 | 31 | Args: 32 | model: The model to use (OpenAI or Anthropic) 33 | client: The client wrapper (OpenAI or Anthropic) 34 | context: Previous summary, if any 35 | messages: New messages to summarize 36 | 37 | Returns: 38 | Tuple of (summary, tokens_used) 39 | """ 40 | # Reverse messages to put the most recent ones last 41 | messages.reverse() 42 | messages_joined = "\n".join(messages) 43 | prev_summary = context or "" 44 | 45 | # Prompt template for progressive summarization 46 | progressive_prompt = f""" 47 | You are a precise summarization assistant. Your task is to progressively 48 | summarize conversation history while maintaining critical context and accuracy. 49 | 50 | INSTRUCTIONS: 51 | 1. Build upon the previous summary by incorporating new information chronologically 52 | 2. Preserve key details: names, technical terms, code references, and important decisions 53 | 3. Maintain the temporal sequence of events and discussions 54 | 4. For technical discussions, keep specific terms, versions, and implementation details 55 | 5. For code-related content, preserve function names, file paths, and important parameters 56 | 6. If the new content is irrelevant or doesn't add value, return "NONE" 57 | 7. Keep the summary concise but complete - aim for 2-3 sentences unless more detail is crucial 58 | 8. Use neutral, factual language 59 | 60 | EXAMPLE 61 | Current summary: 62 | The user inquires about retirement investment options, specifically comparing 63 | traditional IRAs and Roth IRAs. The assistant explains the key differences in 64 | tax treatment, with traditional IRAs offering immediate tax deductions and Roth 65 | IRAs providing tax-free withdrawals in retirement. 66 | 67 | New lines of conversation: 68 | Human: What factors should I consider when deciding between the two? 69 | Assistant: Several key factors influence this decision: 1) Your current tax 70 | bracket vs. expected retirement tax bracket, 2) Time horizon until retirement, 71 | 3) Current income and eligibility for Roth IRA contributions, and 4) Desire for 72 | flexibility in retirement withdrawals. For example, if you expect to be in a 73 | higher tax bracket during retirement, a Roth IRA might be more advantageous 74 | since qualified withdrawals are tax-free. Additionally, Roth IRAs don't have 75 | required minimum distributions (RMDs) during your lifetime, offering more 76 | flexibility in estate planning. 77 | 78 | New summary: 79 | The discussion covers retirement investment options, comparing traditional and 80 | Roth IRAs' tax implications, with traditional IRAs offering immediate deductions 81 | and Roth IRAs providing tax-free withdrawals. The conversation expands to cover 82 | decision factors including current vs. future tax brackets, retirement timeline, 83 | income eligibility, and withdrawal flexibility, with specific emphasis on Roth 84 | IRA advantages for those expecting higher retirement tax brackets and the 85 | benefit of no required minimum distributions. END OF EXAMPLE 86 | 87 | Current summary: 88 | {prev_summary} 89 | 90 | New lines of conversation: 91 | {messages_joined} 92 | 93 | New summary: 94 | """ 95 | 96 | try: 97 | # Get completion from client 98 | response = await client.create_chat_completion(model, progressive_prompt) 99 | 100 | # Extract completion text 101 | completion = response.choices[0].message.content 102 | 103 | # Get token usage 104 | tokens_used = response.total_tokens 105 | 106 | logger.info(f"Summarization complete, using {tokens_used} tokens") 107 | return completion, tokens_used 108 | except Exception as e: 109 | logger.error(f"Error in incremental summarization: {e}") 110 | raise 111 | 112 | 113 | async def summarize_session( 114 | session_id: str, 115 | model: str, 116 | window_size: int, 117 | ) -> None: 118 | """ 119 | Summarize messages in a session when they exceed the window size. 120 | 121 | This function: 122 | 1. Gets the oldest messages up to window size and current context 123 | 2. Generates a new summary that includes these messages 124 | 3. Removes older, summarized messages and updates the context 125 | 126 | Stop summarizing 127 | 128 | Args: 129 | session_id: The session ID 130 | model: The model to use 131 | window_size: Maximum number of messages to keep 132 | client: The client wrapper (OpenAI or Anthropic) 133 | redis_conn: Redis connection 134 | """ 135 | print("Summarizing session") 136 | redis = await get_redis_conn() 137 | client = await get_model_client(settings.generation_model) 138 | 139 | messages_key = Keys.messages_key(session_id) 140 | metadata_key = Keys.metadata_key(session_id) 141 | 142 | async with redis.pipeline(transaction=False) as pipe: 143 | await pipe.watch(messages_key, metadata_key) 144 | 145 | num_messages = await pipe.llen(messages_key) # type: ignore 146 | print(f" Number of messages: {num_messages}") 147 | if num_messages < window_size: 148 | logger.info(f"Not enough messages to summarize for session {session_id}") 149 | return 150 | 151 | messages_raw = await pipe.lrange(messages_key, 0, window_size - 1) # type: ignore 152 | metadata = await pipe.hgetall(metadata_key) # type: ignore 153 | pipe.multi() 154 | 155 | while True: 156 | try: 157 | messages = [] 158 | for msg_raw in messages_raw: 159 | if isinstance(msg_raw, bytes): 160 | msg_raw = msg_raw.decode("utf-8") 161 | msg_dict = json.loads(msg_raw) 162 | messages.append(MemoryMessage(**msg_dict)) 163 | 164 | print("Messages: ", messages) 165 | 166 | model_config = get_model_config(model) 167 | max_tokens = model_config.max_tokens 168 | 169 | # Token allocation: 170 | # - For small context (<10k): use 12.5% (min 512) 171 | # - For medium context (10k-50k): use 10% (min 1024) 172 | # - For large context (>50k): use 5% (min 2048) 173 | if max_tokens < 10000: 174 | summary_max_tokens = max(512, max_tokens // 8) # 12.5% 175 | elif max_tokens < 50000: 176 | summary_max_tokens = max(1024, max_tokens // 10) # 10% 177 | else: 178 | summary_max_tokens = max(2048, max_tokens // 20) # 5% 179 | 180 | # Scale buffer tokens with context size, but keep reasonable bounds 181 | buffer_tokens = min(max(230, max_tokens // 100), 1000) 182 | 183 | max_message_tokens = max_tokens - summary_max_tokens - buffer_tokens 184 | encoding = tiktoken.get_encoding("cl100k_base") 185 | total_tokens = 0 186 | messages_to_summarize = [] 187 | 188 | for msg in messages: 189 | msg_str = f"{msg.role}: {msg.content}" 190 | msg_tokens = len(encoding.encode(msg_str)) 191 | 192 | # TODO: Here, we take a partial message if a single message's 193 | # total size exceeds the buffer. Should this be configurable 194 | # behavior? 195 | if msg_tokens > max_message_tokens: 196 | msg_str = msg_str[: max_message_tokens // 2] 197 | msg_tokens = len(encoding.encode(msg_str)) 198 | total_tokens += msg_tokens 199 | 200 | if total_tokens + msg_tokens <= max_message_tokens: 201 | total_tokens += msg_tokens 202 | messages_to_summarize.append(msg_str) 203 | 204 | if not messages_to_summarize: 205 | logger.info(f"No messages to summarize for session {session_id}") 206 | return 207 | 208 | context = metadata.get("context", "") 209 | 210 | summary, summary_tokens_used = await _incremental_summary( 211 | model, 212 | client, 213 | context, 214 | messages_to_summarize, 215 | ) 216 | total_tokens += summary_tokens_used 217 | 218 | metadata["context"] = summary 219 | metadata["tokens"] = str(total_tokens) 220 | 221 | pipe.hmset(metadata_key, mapping=metadata) 222 | print("Metadata: ", metadata_key, metadata) 223 | 224 | # Messages that were summarized 225 | num_summarized = len(messages_to_summarize) 226 | pipe.ltrim(messages_key, 0, num_summarized - 1) 227 | 228 | await pipe.execute() 229 | break 230 | except WatchError: 231 | continue 232 | 233 | logger.info(f"Summarization complete for session {session_id}") 234 | -------------------------------------------------------------------------------- /agent_memory_server/test_config.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | 3 | import yaml 4 | 5 | from agent_memory_server.config import Settings, load_yaml_settings 6 | 7 | 8 | def test_defaults(monkeypatch): 9 | # Clear env vars 10 | monkeypatch.delenv("APP_CONFIG_FILE", raising=False) 11 | monkeypatch.delenv("redis_url", raising=False) 12 | # No YAML file 13 | monkeypatch.chdir(tempfile.gettempdir()) 14 | settings = Settings() 15 | assert settings.redis_url == "redis://localhost:6379" 16 | assert settings.port == 8000 17 | assert settings.log_level == "INFO" 18 | 19 | 20 | def test_yaml_loading(tmp_path, monkeypatch): 21 | config = {"redis_url": "redis://test:6379", "port": 1234, "log_level": "DEBUG"} 22 | yaml_path = tmp_path / "config.yaml" 23 | with open(yaml_path, "w") as f: 24 | yaml.dump(config, f) 25 | monkeypatch.setenv("APP_CONFIG_FILE", str(yaml_path)) 26 | # Remove env var overrides 27 | monkeypatch.delenv("redis_url", raising=False) 28 | monkeypatch.delenv("port", raising=False) 29 | monkeypatch.delenv("log_level", raising=False) 30 | loaded = load_yaml_settings() 31 | settings = Settings(**loaded) 32 | assert settings.redis_url == "redis://test:6379" 33 | assert settings.port == 1234 34 | assert settings.log_level == "DEBUG" 35 | 36 | 37 | def test_env_overrides_yaml(tmp_path, monkeypatch): 38 | config = {"redis_url": "redis://yaml:6379", "port": 1111} 39 | yaml_path = tmp_path / "config.yaml" 40 | with open(yaml_path, "w") as f: 41 | yaml.dump(config, f) 42 | monkeypatch.setenv("APP_CONFIG_FILE", str(yaml_path)) 43 | monkeypatch.setenv("redis_url", "redis://env:6379") 44 | monkeypatch.setenv("port", "2222") 45 | loaded = load_yaml_settings() 46 | settings = Settings(**loaded) 47 | # Env vars should override YAML 48 | assert settings.redis_url == "redis://env:6379" 49 | assert settings.port == 2222 # Pydantic auto-casts 50 | 51 | 52 | def test_custom_config_path(tmp_path, monkeypatch): 53 | config = {"redis_url": "redis://custom:6379"} 54 | custom_path = tmp_path / "custom.yaml" 55 | with open(custom_path, "w") as f: 56 | yaml.dump(config, f) 57 | monkeypatch.setenv("APP_CONFIG_FILE", str(custom_path)) 58 | loaded = load_yaml_settings() 59 | settings = Settings(**loaded) 60 | assert settings.redis_url == "redis://custom:6379" 61 | -------------------------------------------------------------------------------- /agent_memory_server/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/agent-memory-server/6d4236e45089b3ceb5762fbbe93bc090e4746985/agent_memory_server/utils/__init__.py -------------------------------------------------------------------------------- /agent_memory_server/utils/api_keys.py: -------------------------------------------------------------------------------- 1 | """API key management utilities.""" 2 | 3 | import logging 4 | import os 5 | 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def load_api_key(service: str) -> str | None: 11 | """Load an API key from the environment. 12 | 13 | Args: 14 | service: The service name, e.g., 'openai', 'anthropic' 15 | 16 | Returns: 17 | The API key if found, or None 18 | """ 19 | env_var = f"{service.upper()}_API_KEY" 20 | api_key = os.environ.get(env_var) 21 | 22 | if not api_key: 23 | logger.warning(f"No API key found for {service} (${env_var})") 24 | return None 25 | 26 | return api_key 27 | -------------------------------------------------------------------------------- /agent_memory_server/utils/keys.py: -------------------------------------------------------------------------------- 1 | """Redis key utilities.""" 2 | 3 | import logging 4 | 5 | from agent_memory_server.config import settings 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class Keys: 12 | """Redis key utilities.""" 13 | 14 | @staticmethod 15 | def context_key(session_id: str, namespace: str | None = None) -> str: 16 | """Get the context key for a session.""" 17 | return ( 18 | f"context:{namespace}:{session_id}" 19 | if namespace 20 | else f"context:{session_id}" 21 | ) 22 | 23 | @staticmethod 24 | def token_count_key(session_id: str, namespace: str | None = None) -> str: 25 | """Get the token count key for a session.""" 26 | return ( 27 | f"tokens:{namespace}:{session_id}" if namespace else f"tokens:{session_id}" 28 | ) 29 | 30 | @staticmethod 31 | def messages_key(session_id: str, namespace: str | None = None) -> str: 32 | """Get the messages key for a session.""" 33 | return ( 34 | f"messages:{namespace}:{session_id}" 35 | if namespace 36 | else f"messages:{session_id}" 37 | ) 38 | 39 | @staticmethod 40 | def sessions_key(namespace: str | None = None) -> str: 41 | """Get the sessions key for a namespace.""" 42 | return f"sessions:{namespace}" if namespace else "sessions" 43 | 44 | @staticmethod 45 | def memory_key(id: str, namespace: str | None = None) -> str: 46 | """Get the memory key for an ID.""" 47 | return f"memory:{namespace}:{id}" if namespace else f"memory:{id}" 48 | 49 | @staticmethod 50 | def metadata_key(session_id: str, namespace: str | None = None) -> str: 51 | """Get the metadata key for a session.""" 52 | return ( 53 | f"metadata:{namespace}:{session_id}" 54 | if namespace 55 | else f"metadata:{session_id}" 56 | ) 57 | 58 | @staticmethod 59 | def working_memory_key(session_id: str, namespace: str | None = None) -> str: 60 | """Get the working memory key for a session.""" 61 | return ( 62 | f"working_memory:{namespace}:{session_id}" 63 | if namespace 64 | else f"working_memory:{session_id}" 65 | ) 66 | 67 | @staticmethod 68 | def search_index_name() -> str: 69 | """Return the name of the search index.""" 70 | return settings.redisvl_index_name 71 | -------------------------------------------------------------------------------- /agent_memory_server/utils/redis.py: -------------------------------------------------------------------------------- 1 | """Redis utility functions.""" 2 | 3 | import logging 4 | from typing import Any 5 | 6 | from redis.asyncio import Redis 7 | from redisvl.index import AsyncSearchIndex 8 | from redisvl.schema import IndexSchema 9 | 10 | from agent_memory_server.config import settings 11 | 12 | 13 | logger = logging.getLogger(__name__) 14 | _redis_pool: Redis | None = None 15 | _index: AsyncSearchIndex | None = None 16 | 17 | 18 | async def get_redis_conn(url: str = settings.redis_url, **kwargs) -> Redis: 19 | """Get a Redis connection. 20 | 21 | Args: 22 | url: Redis connection URL, or None to use settings.redis_url 23 | **kwargs: Additional arguments to pass to Redis.from_url 24 | 25 | Returns: 26 | A Redis client instance 27 | """ 28 | global _redis_pool 29 | 30 | # Always use the existing _redis_pool if it's not None, regardless of the URL parameter 31 | # This ensures that the patched _redis_pool from the test fixture is used 32 | if _redis_pool is None: 33 | _redis_pool = Redis.from_url(url, **kwargs) 34 | return _redis_pool 35 | 36 | 37 | def get_search_index( 38 | redis: Redis, 39 | index_name: str = settings.redisvl_index_name, 40 | vector_dimensions: str = settings.redisvl_vector_dimensions, 41 | distance_metric: str = settings.redisvl_distance_metric, 42 | ) -> AsyncSearchIndex: 43 | global _index 44 | if _index is None: 45 | schema = { 46 | "index": { 47 | "name": index_name, 48 | "prefix": f"{index_name}:", 49 | "key_separator": ":", 50 | "storage_type": "hash", 51 | }, 52 | "fields": [ 53 | {"name": "text", "type": "text"}, 54 | {"name": "memory_hash", "type": "tag"}, 55 | {"name": "id_", "type": "tag"}, 56 | {"name": "session_id", "type": "tag"}, 57 | {"name": "user_id", "type": "tag"}, 58 | {"name": "namespace", "type": "tag"}, 59 | {"name": "topics", "type": "tag"}, 60 | {"name": "entities", "type": "tag"}, 61 | {"name": "created_at", "type": "numeric"}, 62 | {"name": "last_accessed", "type": "numeric"}, 63 | {"name": "memory_type", "type": "tag"}, 64 | {"name": "discrete_memory_extracted", "type": "tag"}, 65 | {"name": "id", "type": "tag"}, 66 | {"name": "persisted_at", "type": "numeric"}, 67 | {"name": "extracted_from", "type": "tag"}, 68 | {"name": "event_date", "type": "numeric"}, 69 | { 70 | "name": "vector", 71 | "type": "vector", 72 | "attrs": { 73 | "algorithm": "HNSW", 74 | "dims": int(vector_dimensions), 75 | "distance_metric": distance_metric, 76 | "datatype": "float32", 77 | }, 78 | }, 79 | ], 80 | } 81 | index_schema = IndexSchema.from_dict(schema) 82 | _index = AsyncSearchIndex(index_schema, redis_client=redis) 83 | return _index 84 | 85 | 86 | async def ensure_search_index_exists( 87 | redis: Redis, 88 | index_name: str = settings.redisvl_index_name, 89 | vector_dimensions: str = settings.redisvl_vector_dimensions, 90 | distance_metric: str = settings.redisvl_distance_metric, 91 | overwrite: bool = False, 92 | ) -> None: 93 | """ 94 | Ensure that the async search index exists, create it if it doesn't. 95 | Uses RedisVL's AsyncSearchIndex. 96 | 97 | Args: 98 | redis: A Redis client instance 99 | vector_dimensions: Dimensions of the embedding vectors 100 | distance_metric: Distance metric to use (default: COSINE) 101 | index_name: The name of the index 102 | """ 103 | index = get_search_index(redis, index_name, vector_dimensions, distance_metric) 104 | if await index.exists(): 105 | logger.info("Async search index already exists") 106 | if overwrite: 107 | logger.info("Overwriting existing index") 108 | await redis.execute_command("FT.DROPINDEX", index.name) 109 | else: 110 | return 111 | else: 112 | logger.info("Async search index doesn't exist, creating...") 113 | 114 | await index.create() 115 | 116 | logger.info( 117 | f"Created async search index with {vector_dimensions} dimensions and {distance_metric} metric" 118 | ) 119 | 120 | 121 | def safe_get(doc: Any, key: str, default: Any | None = None) -> Any: 122 | """Get a value from a Document, returning a default if the key is not present. 123 | 124 | Args: 125 | doc: Document or object to get a value from 126 | key: Key to get 127 | default: Default value to return if key is not found 128 | 129 | Returns: 130 | The value if found, or the default 131 | """ 132 | if isinstance(doc, dict): 133 | return doc.get(key, default) 134 | try: 135 | return getattr(doc, key) 136 | except (AttributeError, KeyError): 137 | return default 138 | -------------------------------------------------------------------------------- /agent_memory_server/working_memory.py: -------------------------------------------------------------------------------- 1 | """Working memory management for sessions.""" 2 | 3 | import json 4 | import logging 5 | import time 6 | from datetime import UTC, datetime 7 | 8 | from redis.asyncio import Redis 9 | 10 | from agent_memory_server.models import MemoryMessage, MemoryRecord, WorkingMemory 11 | from agent_memory_server.utils.keys import Keys 12 | from agent_memory_server.utils.redis import get_redis_conn 13 | 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | def json_datetime_handler(obj): 19 | """JSON serializer for datetime objects.""" 20 | if isinstance(obj, datetime): 21 | return obj.isoformat() 22 | raise TypeError(f"Object of type {type(obj)} is not JSON serializable") 23 | 24 | 25 | async def list_sessions( 26 | redis, 27 | limit: int = 10, 28 | offset: int = 0, 29 | namespace: str | None = None, 30 | ) -> tuple[int, list[str]]: 31 | """List sessions""" 32 | # Calculate start and end indices (0-indexed start, inclusive end) 33 | start = offset 34 | end = offset + limit - 1 35 | 36 | sessions_key = Keys.sessions_key(namespace=namespace) 37 | 38 | async with redis.pipeline() as pipe: 39 | pipe.zcard(sessions_key) 40 | pipe.zrange(sessions_key, start, end) 41 | total, session_ids = await pipe.execute() 42 | 43 | return total, [ 44 | s.decode("utf-8") if isinstance(s, bytes) else s for s in session_ids 45 | ] 46 | 47 | 48 | async def get_working_memory( 49 | session_id: str, 50 | namespace: str | None = None, 51 | redis_client: Redis | None = None, 52 | effective_window_size: int | None = None, 53 | ) -> WorkingMemory | None: 54 | """ 55 | Get working memory for a session. 56 | 57 | Args: 58 | session_id: The session ID 59 | namespace: Optional namespace for the session 60 | redis_client: Optional Redis client 61 | 62 | Returns: 63 | WorkingMemory object or None if not found 64 | """ 65 | if not redis_client: 66 | redis_client = await get_redis_conn() 67 | 68 | key = Keys.working_memory_key(session_id, namespace) 69 | 70 | try: 71 | data = await redis_client.get(key) 72 | if not data: 73 | return None 74 | 75 | # Parse the JSON data 76 | working_memory_data = json.loads(data) 77 | 78 | # Convert memory records back to MemoryRecord objects 79 | memories = [] 80 | for memory_data in working_memory_data.get("memories", []): 81 | memory = MemoryRecord(**memory_data) 82 | memories.append(memory) 83 | 84 | # Convert messages back to MemoryMessage objects 85 | messages = [] 86 | for message_data in working_memory_data.get("messages", []): 87 | message = MemoryMessage(**message_data) 88 | messages.append(message) 89 | 90 | return WorkingMemory( 91 | messages=messages, 92 | memories=memories, 93 | context=working_memory_data.get("context"), 94 | user_id=working_memory_data.get("user_id"), 95 | tokens=working_memory_data.get("tokens", 0), 96 | session_id=session_id, 97 | namespace=namespace, 98 | ttl_seconds=working_memory_data.get("ttl_seconds", 3600), 99 | data=working_memory_data.get("data") or {}, 100 | last_accessed=datetime.fromtimestamp( 101 | working_memory_data.get("last_accessed", int(time.time())), UTC 102 | ), 103 | created_at=datetime.fromtimestamp( 104 | working_memory_data.get("created_at", int(time.time())), UTC 105 | ), 106 | updated_at=datetime.fromtimestamp( 107 | working_memory_data.get("updated_at", int(time.time())), UTC 108 | ), 109 | ) 110 | 111 | except Exception as e: 112 | logger.error(f"Error getting working memory for session {session_id}: {e}") 113 | return None 114 | 115 | 116 | async def set_working_memory( 117 | working_memory: WorkingMemory, 118 | redis_client: Redis | None = None, 119 | ) -> None: 120 | """ 121 | Set working memory for a session with TTL. 122 | 123 | Args: 124 | working_memory: WorkingMemory object to store 125 | redis_client: Optional Redis client 126 | """ 127 | if not redis_client: 128 | redis_client = await get_redis_conn() 129 | 130 | # Validate that all memories have id (Stage 3 requirement) 131 | for memory in working_memory.memories: 132 | if not memory.id: 133 | raise ValueError("All memory records in working memory must have an id") 134 | 135 | key = Keys.working_memory_key(working_memory.session_id, working_memory.namespace) 136 | 137 | # Update the updated_at timestamp 138 | working_memory.updated_at = datetime.now(UTC) 139 | 140 | # Convert to JSON-serializable format with timestamp conversion 141 | data = { 142 | "messages": [ 143 | message.model_dump(mode="json") for message in working_memory.messages 144 | ], 145 | "memories": [ 146 | memory.model_dump(mode="json") for memory in working_memory.memories 147 | ], 148 | "context": working_memory.context, 149 | "user_id": working_memory.user_id, 150 | "tokens": working_memory.tokens, 151 | "session_id": working_memory.session_id, 152 | "namespace": working_memory.namespace, 153 | "ttl_seconds": working_memory.ttl_seconds, 154 | "data": working_memory.data or {}, 155 | "last_accessed": int(working_memory.last_accessed.timestamp()), 156 | "created_at": int(working_memory.created_at.timestamp()), 157 | "updated_at": int(working_memory.updated_at.timestamp()), 158 | } 159 | 160 | try: 161 | # Store with TTL 162 | await redis_client.setex( 163 | key, 164 | working_memory.ttl_seconds, 165 | json.dumps( 166 | data, default=json_datetime_handler 167 | ), # Add custom handler for any remaining datetime objects 168 | ) 169 | logger.info( 170 | f"Set working memory for session {working_memory.session_id} with TTL {working_memory.ttl_seconds}s" 171 | ) 172 | 173 | except Exception as e: 174 | logger.error( 175 | f"Error setting working memory for session {working_memory.session_id}: {e}" 176 | ) 177 | raise 178 | 179 | 180 | async def delete_working_memory( 181 | session_id: str, 182 | namespace: str | None = None, 183 | redis_client: Redis | None = None, 184 | ) -> None: 185 | """ 186 | Delete working memory for a session. 187 | 188 | Args: 189 | session_id: The session ID 190 | namespace: Optional namespace for the session 191 | redis_client: Optional Redis client 192 | """ 193 | if not redis_client: 194 | redis_client = await get_redis_conn() 195 | 196 | key = Keys.working_memory_key(session_id, namespace) 197 | 198 | try: 199 | await redis_client.delete(key) 200 | logger.info(f"Deleted working memory for session {session_id}") 201 | 202 | except Exception as e: 203 | logger.error(f"Error deleting working memory for session {session_id}: {e}") 204 | raise 205 | -------------------------------------------------------------------------------- /claude.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/agent-memory-server/6d4236e45089b3ceb5762fbbe93bc090e4746985/claude.png -------------------------------------------------------------------------------- /cursor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/agent-memory-server/6d4236e45089b3ceb5762fbbe93bc090e4746985/cursor.png -------------------------------------------------------------------------------- /diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/agent-memory-server/6d4236e45089b3ceb5762fbbe93bc090e4746985/diagram.png -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | api: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile 6 | ports: 7 | - "8000:8000" 8 | environment: 9 | - REDIS_URL=redis://redis:6379 10 | - PORT=8000 11 | # Add your API keys here or use a .env file 12 | - OPENAI_API_KEY=${OPENAI_API_KEY} 13 | - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} 14 | # Optional configurations with defaults 15 | - LONG_TERM_MEMORY=True 16 | - WINDOW_SIZE=20 17 | - GENERATION_MODEL=gpt-4o-mini 18 | - EMBEDDING_MODEL=text-embedding-3-small 19 | - ENABLE_TOPIC_EXTRACTION=True 20 | - ENABLE_NER=True 21 | depends_on: 22 | - redis 23 | volumes: 24 | - ./agent_memory_server:/app/agent_memory_server 25 | healthcheck: 26 | test: [ "CMD", "curl", "-f", "http://localhost:8000/health" ] 27 | interval: 30s 28 | timeout: 10s 29 | retries: 3 30 | 31 | mcp: 32 | build: 33 | context: . 34 | dockerfile: Dockerfile 35 | environment: 36 | - REDIS_URL=redis://redis:6379 37 | - PORT=9000 38 | # Add your API keys here or use a .env file 39 | - OPENAI_API_KEY=${OPENAI_API_KEY} 40 | - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} 41 | # Optional configurations with defaults 42 | - LONG_TERM_MEMORY=True 43 | - WINDOW_SIZE=20 44 | - GENERATION_MODEL=gpt-4o-mini 45 | - EMBEDDING_MODEL=text-embedding-3-small 46 | - ENABLE_TOPIC_EXTRACTION=True 47 | - ENABLE_NER=True 48 | ports: 49 | - "9000:9000" 50 | depends_on: 51 | - redis 52 | command: ["uv", "run", "agent-memory", "mcp", "--mode", "sse"] 53 | 54 | redis: 55 | image: redis/redis-stack:latest 56 | ports: 57 | - "16379:6379" # Redis port 58 | - "18001:8001" # RedisInsight port 59 | volumes: 60 | - redis_data:/data 61 | command: redis-stack-server --save 60 1 --loglevel warning 62 | healthcheck: 63 | test: [ "CMD", "redis-cli", "ping" ] 64 | interval: 30s 65 | timeout: 10s 66 | retries: 3 67 | 68 | volumes: 69 | redis_data: 70 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Redis Agent Memory Server Documentation 2 | 3 | This directory contains comprehensive documentation for the Redis Agent Memory Server. 4 | 5 | ## Documentation Index 6 | 7 | ### Core Documentation 8 | 9 | - **[Authentication](authentication.md)** - OAuth2/JWT setup, configuration, and security best practices 10 | - **[REST API](api.md)** - Complete API reference with endpoints and examples 11 | - **[MCP Server](mcp.md)** - Model Context Protocol interface and client setup 12 | - **[CLI](cli.md)** - Command-line interface reference and examples 13 | 14 | ### Setup and Configuration 15 | 16 | - **[Getting Started](getting-started.md)** - Installation, running servers, and Docker setup 17 | - **[Configuration](configuration.md)** - Environment variables, background tasks, and memory management 18 | 19 | ### Development 20 | 21 | - **[Development](development.md)** - Testing, contributing, and development setup 22 | 23 | ### Additional Resources 24 | 25 | - **[Manual OAuth Testing](../manual_oauth_qa/README.md)** - Comprehensive Auth0 testing guide 26 | - **[Main README](../README.md)** - Project overview and quick reference 27 | 28 | ## Quick Links 29 | 30 | - **Start here**: [Getting Started](getting-started.md) 31 | - **API Reference**: [REST API](api.md) 32 | - **Authentication Setup**: [Authentication](authentication.md) 33 | - **MCP Integration**: [MCP Server](mcp.md) 34 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # REST API Endpoints 2 | 3 | The following endpoints are available: 4 | 5 | - **GET /v1/health** 6 | A simple health check endpoint returning the current server time. 7 | Example Response: 8 | 9 | ```json 10 | { "now": 1616173200 } 11 | ``` 12 | 13 | - **GET /v1/working-memory/** 14 | Retrieves a paginated list of session IDs. 15 | _Query Parameters:_ 16 | 17 | - `limit` (int): Number of sessions per page (default: 10) 18 | - `offset` (int): Number of sessions to skip (default: 0) 19 | - `namespace` (string, optional): Filter sessions by namespace. 20 | 21 | - **GET /v1/working-memory/{session_id}** 22 | Retrieves working memory for a session, including messages, structured memories, 23 | context, and metadata. 24 | _Query Parameters:_ 25 | 26 | - `namespace` (string, optional): The namespace to use for the session 27 | - `window_size` (int, optional): Number of messages to include in the response (default from config) 28 | - `model_name` (string, optional): The client's LLM model name to determine appropriate context window size 29 | - `context_window_max` (int, optional): Direct specification of max context window tokens (overrides model_name) 30 | 31 | - **PUT /v1/working-memory/{session_id}** 32 | Sets working memory for a session, replacing any existing memory. 33 | Automatically summarizes conversations that exceed the window size. 34 | _Request Body Example:_ 35 | 36 | ```json 37 | { 38 | "messages": [ 39 | { "role": "user", "content": "Hello" }, 40 | { "role": "assistant", "content": "Hi there" } 41 | ], 42 | "memories": [ 43 | { 44 | "id": "mem-123", 45 | "text": "User prefers direct communication", 46 | "memory_type": "semantic" 47 | } 48 | ], 49 | "context": "Previous conversation summary...", 50 | "session_id": "session-123", 51 | "namespace": "default" 52 | } 53 | ``` 54 | 55 | - **DELETE /v1/working-memory/{session_id}** 56 | Deletes all working memory (messages, context, structured memories, metadata) for a session. 57 | 58 | - **POST /v1/long-term-memory/** 59 | Creates long-term memories directly, bypassing working memory. 60 | _Request Body Example:_ 61 | 62 | ```json 63 | { 64 | "memories": [ 65 | { 66 | "id": "mem-456", 67 | "text": "User is interested in AI and machine learning", 68 | "memory_type": "semantic", 69 | "session_id": "session-123", 70 | "namespace": "default" 71 | } 72 | ] 73 | } 74 | ``` 75 | 76 | - **POST /v1/long-term-memory/search** 77 | Performs vector search on long-term memories with advanced filtering options. 78 | _Request Body Example:_ 79 | 80 | ```json 81 | { 82 | "text": "Search query text", 83 | "limit": 10, 84 | "offset": 0, 85 | "session_id": { "eq": "session-123" }, 86 | "namespace": { "eq": "default" }, 87 | "topics": { "any": ["AI", "Machine Learning"] }, 88 | "entities": { "all": ["OpenAI", "Claude"] }, 89 | "created_at": { "gte": 1672527600, "lte": 1704063599 }, 90 | "last_accessed": { "gt": 1704063600 }, 91 | "user_id": { "eq": "user-456" } 92 | } 93 | ``` 94 | 95 | - **POST /v1/memory/prompt** 96 | Generates prompts enriched with relevant memory context from both working 97 | memory and long-term memory. Useful for retrieving context before answering questions. 98 | _Request Body Example:_ 99 | 100 | ```json 101 | { 102 | "query": "What did we discuss about AI?", 103 | "session": { 104 | "session_id": "session-123", 105 | "namespace": "default", 106 | "window_size": 10 107 | }, 108 | "long_term_search": { 109 | "text": "AI discussion", 110 | "limit": 5, 111 | "namespace": { "eq": "default" } 112 | } 113 | } 114 | ``` 115 | 116 | ## Filter Options 117 | 118 | _Filter options for search endpoints:_ 119 | 120 | - Tag filters (session_id, namespace, topics, entities, user_id): 121 | 122 | - `eq`: Equals this value 123 | - `ne`: Not equals this value 124 | - `any`: Contains any of these values 125 | - `all`: Contains all of these values 126 | 127 | - Numeric filters (created_at, last_accessed): 128 | - `gt`: Greater than 129 | - `lt`: Less than 130 | - `gte`: Greater than or equal 131 | - `lte`: Less than or equal 132 | - `eq`: Equals 133 | - `ne`: Not equals 134 | - `between`: Between two values 135 | -------------------------------------------------------------------------------- /docs/authentication.md: -------------------------------------------------------------------------------- 1 | # Authentication 2 | 3 | The Redis Agent Memory Server supports OAuth2/JWT Bearer token authentication for secure API access. All API endpoints (except `/health`, `/docs`, and `/openapi.json`) require valid JWT authentication unless disabled for development. 4 | 5 | ## Features 6 | 7 | - **OAuth2/JWT Bearer Token Authentication**: Industry-standard authentication using JWT access tokens 8 | - **JWKS Public Key Validation**: Automatic fetching and caching of public keys for token signature verification 9 | - **Multi-Provider Support**: Compatible with Auth0, AWS Cognito, Okta, Azure AD, and any standard OAuth2 provider 10 | - **Flexible Configuration**: Environment variable-based configuration for different deployment scenarios 11 | - **Development Mode**: `DISABLE_AUTH` setting for local development and testing 12 | - **Role and Scope Support**: Fine-grained access control using JWT claims 13 | 14 | ## Configuration 15 | 16 | Authentication is configured using environment variables: 17 | 18 | ```bash 19 | # OAuth2 Provider Configuration 20 | OAUTH2_ISSUER_URL=https://your-auth-provider.com 21 | OAUTH2_AUDIENCE=your-api-audience 22 | OAUTH2_JWKS_URL=https://your-auth-provider.com/.well-known/jwks.json # Optional, auto-derived from issuer 23 | OAUTH2_ALGORITHMS=["RS256"] # Supported signing algorithms 24 | 25 | # Development Mode (DISABLE AUTHENTICATION - USE ONLY FOR DEVELOPMENT) 26 | DISABLE_AUTH=true # Set to true to bypass all authentication (development only) 27 | ``` 28 | 29 | ## Provider Examples 30 | 31 | ### Auth0 32 | 33 | ```bash 34 | OAUTH2_ISSUER_URL=https://your-domain.auth0.com/ 35 | OAUTH2_AUDIENCE=https://your-api.com 36 | ``` 37 | 38 | ### AWS Cognito 39 | 40 | ```bash 41 | OAUTH2_ISSUER_URL=https://cognito-idp.region.amazonaws.com/your-user-pool-id 42 | OAUTH2_AUDIENCE=your-app-client-id 43 | ``` 44 | 45 | ### Okta 46 | 47 | ```bash 48 | OAUTH2_ISSUER_URL=https://your-domain.okta.com/oauth2/default 49 | OAUTH2_AUDIENCE=api://default 50 | ``` 51 | 52 | ### Azure AD 53 | 54 | ```bash 55 | OAUTH2_ISSUER_URL=https://login.microsoftonline.com/your-tenant-id/v2.0 56 | OAUTH2_AUDIENCE=your-application-id 57 | ``` 58 | 59 | ## Usage Examples 60 | 61 | ### With Authentication (Production) 62 | 63 | ```bash 64 | # Make authenticated API request 65 | curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \ 66 | -H "Content-Type: application/json" \ 67 | http://localhost:8000/v1/working-memory/ 68 | 69 | # Python example 70 | import httpx 71 | 72 | headers = { 73 | "Authorization": "Bearer YOUR_JWT_TOKEN", 74 | "Content-Type": "application/json" 75 | } 76 | 77 | response = httpx.get("http://localhost:8000/v1/working-memory/", headers=headers) 78 | ``` 79 | 80 | ### Development Mode (Local Testing) 81 | 82 | ```bash 83 | # Set environment variable to disable auth 84 | export DISABLE_AUTH=true 85 | 86 | # Now you can make requests without tokens 87 | curl -H "Content-Type: application/json" \ 88 | http://localhost:8000/v1/working-memory/ 89 | ``` 90 | 91 | ## Token Requirements 92 | 93 | JWT tokens must include: 94 | 95 | - **Valid signature**: Verified using JWKS public keys from the issuer 96 | - **Not expired**: `exp` claim must be in the future 97 | - **Valid audience**: `aud` claim must match `OAUTH2_AUDIENCE` (if configured) 98 | - **Valid issuer**: `iss` claim must match `OAUTH2_ISSUER_URL` 99 | - **Subject**: `sub` claim identifying the user/client 100 | 101 | ## Error Responses 102 | 103 | Authentication failures return HTTP 401 with details: 104 | 105 | ```json 106 | { 107 | "detail": "Invalid JWT: Token has expired", 108 | "status_code": 401 109 | } 110 | ``` 111 | 112 | Common error scenarios: 113 | 114 | - `Missing authorization header`: No `Authorization: Bearer` header provided 115 | - `Missing bearer token`: Empty or malformed authorization header 116 | - `Invalid token header`: Malformed JWT structure 117 | - `Token has expired`: JWT `exp` claim is in the past 118 | - `Invalid audience`: JWT `aud` claim doesn't match expected audience 119 | - `Unable to find matching public key`: JWKS doesn't contain key for token's `kid` 120 | 121 | ## Security Best Practices 122 | 123 | 1. **Never use `DISABLE_AUTH=true` in production** 124 | 2. **Use HTTPS in production** to protect tokens in transit 125 | 3. **Implement token refresh** in your clients for long-running applications 126 | 4. **Monitor token expiration** and handle 401 responses appropriately 127 | 5. **Validate tokens server-side** - never trust client-side validation alone 128 | 6. **Use appropriate scopes/roles** for fine-grained access control 129 | 130 | ## Manual Testing 131 | 132 | For comprehensive Auth0 testing instructions, see the [manual OAuth testing guide](../manual_oauth_qa/README.md). 133 | -------------------------------------------------------------------------------- /docs/cli.md: -------------------------------------------------------------------------------- 1 | # Command Line Interface 2 | 3 | The `agent-memory-server` provides a command-line interface (CLI) for managing the server and related tasks. You can access the CLI using the `agent-memory` command (assuming the package is installed in a way that makes the script available in your PATH, e.g., via `pip install ...`). 4 | 5 | ## Available Commands 6 | 7 | Here's a list of available commands and their functions: 8 | 9 | ### `version` 10 | 11 | Displays the current version of `agent-memory-server`. 12 | 13 | ```bash 14 | agent-memory version 15 | ``` 16 | 17 | ### `api` 18 | 19 | Starts the REST API server. 20 | 21 | ```bash 22 | agent-memory api [OPTIONS] 23 | ``` 24 | 25 | **Options:** 26 | 27 | - `--port INTEGER`: Port to run the server on. (Default: value from `settings.port`, usually 8000) 28 | - `--host TEXT`: Host to run the server on. (Default: "0.0.0.0") 29 | - `--reload`: Enable auto-reload for development. 30 | 31 | Example: 32 | 33 | ```bash 34 | agent-memory api --port 8080 --reload 35 | ``` 36 | 37 | ### `mcp` 38 | 39 | Starts the Model Context Protocol (MCP) server. 40 | 41 | ```bash 42 | agent-memory mcp [OPTIONS] 43 | ``` 44 | 45 | **Options:** 46 | 47 | - `--port INTEGER`: Port to run the MCP server on. (Default: value from `settings.mcp_port`, usually 9000) 48 | - `--sse`: Run the MCP server in Server-Sent Events (SSE) mode. If not provided, it runs in stdio mode. 49 | 50 | Example (SSE mode): 51 | 52 | ```bash 53 | agent-memory mcp --port 9001 --sse 54 | ``` 55 | 56 | Example (stdio mode): 57 | 58 | ```bash 59 | agent-memory mcp --port 9001 60 | ``` 61 | 62 | ### `schedule-task` 63 | 64 | Schedules a background task to be processed by a Docket worker. 65 | 66 | ```bash 67 | agent-memory schedule-task [OPTIONS] 68 | ``` 69 | 70 | **Arguments:** 71 | 72 | - `TASK_PATH`: The Python import path to the task function. For example: `"agent_memory_server.long_term_memory.compact_long_term_memories"` 73 | 74 | **Options:** 75 | 76 | - `--args TEXT` / `-a TEXT`: Arguments to pass to the task in `key=value` format. Can be specified multiple times. Values are automatically converted to boolean, integer, or float if possible, otherwise they remain strings. 77 | 78 | Example: 79 | 80 | ```bash 81 | agent-memory schedule-task "agent_memory_server.long_term_memory.compact_long_term_memories" -a limit=500 -a namespace=my_namespace -a compact_semantic_duplicates=false 82 | ``` 83 | 84 | ### `task-worker` 85 | 86 | Starts a Docket worker to process background tasks from the queue. This worker uses the Docket name configured in settings. 87 | 88 | ```bash 89 | agent-memory task-worker [OPTIONS] 90 | ``` 91 | 92 | **Options:** 93 | 94 | - `--concurrency INTEGER`: Number of tasks to process concurrently. (Default: 10) 95 | - `--redelivery-timeout INTEGER`: Seconds to wait before a task is redelivered to another worker if the current worker fails or times out. (Default: 30) 96 | 97 | Example: 98 | 99 | ```bash 100 | agent-memory task-worker --concurrency 5 --redelivery-timeout 60 101 | ``` 102 | 103 | ### `rebuild_index` 104 | 105 | Rebuilds the search index for Redis Memory Server. 106 | 107 | ```bash 108 | agent-memory rebuild_index 109 | ``` 110 | 111 | ### `migrate_memories` 112 | 113 | Runs data migrations. Migrations are reentrant. 114 | 115 | ```bash 116 | agent-memory migrate_memories 117 | ``` 118 | 119 | ## Getting Help 120 | 121 | To see help for any command, you can use `--help`: 122 | 123 | ```bash 124 | agent-memory --help 125 | agent-memory api --help 126 | agent-memory mcp --help 127 | # etc. 128 | ``` 129 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | You can configure the MCP and REST servers and task worker using environment 4 | variables. See the file `config.py` for all the available settings. 5 | 6 | The names of the settings map directly to an environment variable, so for 7 | example, you can set the `openai_api_key` setting with the `OPENAI_API_KEY` 8 | environment variable. 9 | 10 | ## Running the Background Task Worker 11 | 12 | The Redis Memory Server uses Docket for background task management. You can run a worker instance like this: 13 | 14 | ```bash 15 | uv run agent-memory task-worker 16 | ``` 17 | 18 | You can customize the concurrency and redelivery timeout: 19 | 20 | ```bash 21 | uv run agent-memory task-worker --concurrency 5 --redelivery-timeout 60 22 | ``` 23 | 24 | ## Memory Compaction 25 | 26 | The memory compaction functionality optimizes storage by merging duplicate and semantically similar memories. This improves retrieval quality and reduces storage costs. 27 | 28 | ### Running Compaction 29 | 30 | Memory compaction is available as a task function in `agent_memory_server.long_term_memory.compact_long_term_memories`. You can trigger it manually 31 | by running the `agent-memory schedule-task` command: 32 | 33 | ```bash 34 | uv run agent-memory schedule-task "agent_memory_server.long_term_memory.compact_long_term_memories" 35 | ``` 36 | 37 | ### Key Features 38 | 39 | - **Hash-based Deduplication**: Identifies and merges exact duplicate memories using content hashing 40 | - **Semantic Deduplication**: Finds and merges memories with similar meaning using vector search 41 | - **LLM-powered Merging**: Uses language models to intelligently combine memories 42 | 43 | ## Running Migrations 44 | 45 | When the data model changes, we add a migration in `migrations.py`. You can run 46 | these to make sure your schema is up to date, like so: 47 | 48 | ```bash 49 | uv run agent-memory migrate-memories 50 | ``` 51 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | ## Running Tests 4 | 5 | ```bash 6 | uv run pytest 7 | ``` 8 | 9 | ## Contributing 10 | 11 | 1. Fork the repository 12 | 2. Create a feature branch 13 | 3. Commit your changes 14 | 4. Push to the branch 15 | 5. Create a Pull Request 16 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## Installation 4 | 5 | First, you'll need to download this repository. After you've downloaded it, you can install and run the servers. 6 | 7 | This project uses [uv](https://github.com/astral-sh/uv) for dependency management. 8 | 9 | 1. Install uv: 10 | 11 | ```bash 12 | pip install uv 13 | ``` 14 | 15 | 2. Install the package and required dependencies: 16 | 17 | ```bash 18 | uv sync 19 | ``` 20 | 21 | 3. Set up environment variables (see [Configuration](configuration.md) section) 22 | 23 | ## Running 24 | 25 | The easiest way to start the REST and MCP servers is to use Docker Compose. See the Docker Compose section below for more details. 26 | 27 | But you can also run these servers via the CLI commands. Here's how you 28 | run the REST API server: 29 | 30 | ```bash 31 | uv run agent-memory api 32 | ``` 33 | 34 | And the MCP server: 35 | 36 | ```bash 37 | uv run agent-memory mcp --mode 38 | ``` 39 | 40 | **NOTE:** With uv, prefix the command with `uv`, e.g.: `uv run agent-memory --mode sse`. If you installed from source, you'll probably need to add `--directory` to tell uv where to find the code: `uv run --directory run agent-memory --mode stdio`. 41 | 42 | ## Docker Compose 43 | 44 | To start the API using Docker Compose, follow these steps: 45 | 46 | 1. Ensure that Docker and Docker Compose are installed on your system. 47 | 48 | 2. Open a terminal in the project root directory (where the docker-compose.yml file is located). 49 | 50 | 3. (Optional) Set up your environment variables (such as OPENAI_API_KEY and ANTHROPIC_API_KEY) either in a .env file or by modifying the docker-compose.yml as needed. 51 | 52 | 4. Build and start the containers by running: 53 | ```bash 54 | docker-compose up --build 55 | ``` 56 | 57 | 5. Once the containers are up, the REST API will be available at http://localhost:8000. You can also access the interactive API documentation at http://localhost:8000/docs. The MCP server will be available at http://localhost:9000/sse. 58 | 59 | 6. To stop the containers, press Ctrl+C in the terminal and then run: 60 | ```bash 61 | docker-compose down 62 | ``` 63 | -------------------------------------------------------------------------------- /docs/mcp.md: -------------------------------------------------------------------------------- 1 | # MCP Server Interface 2 | 3 | Agent Memory Server offers an MCP (Model Context Protocol) server interface powered by FastMCP, providing tool-based memory management for LLMs and agents: 4 | 5 | - **set_working_memory**: Set working memory for a session (like PUT /sessions/{id}/memory API). Stores structured memory records and JSON data in working memory with automatic promotion to long-term storage. 6 | - **create_long_term_memories**: Create long-term memories directly, bypassing working memory. Useful for bulk memory creation. 7 | - **search_long_term_memory**: Perform semantic search across long-term memories with advanced filtering options. 8 | - **memory_prompt**: Generate prompts enriched with session context and long-term memories. Essential for retrieving relevant context before answering questions. 9 | 10 | ## Using the MCP Server with Claude Desktop, Cursor, etc. 11 | 12 | You can use the MCP server that comes with this project in any application or SDK that supports MCP tools. 13 | 14 | ### Claude 15 | 16 | 17 | 18 | For example, with Claude, use the following configuration: 19 | 20 | ```json 21 | { 22 | "mcpServers": { 23 | "redis-memory-server": { 24 | "command": "uv", 25 | "args": [ 26 | "--directory", 27 | "/ABSOLUTE/PATH/TO/REPO/DIRECTORY/agent-memory-server", 28 | "run", 29 | "agent-memory", 30 | "-mcp", 31 | "--mode", 32 | "stdio" 33 | ] 34 | } 35 | } 36 | } 37 | ``` 38 | 39 | **NOTE:** On a Mac, this configuration requires that you use `brew install uv` to install uv. Probably any method that makes the `uv` 40 | command globally accessible, so Claude can find it, would work. 41 | 42 | ### Cursor 43 | 44 | 45 | 46 | Cursor's MCP config is similar to Claude's, but it also supports SSE servers, so you can run the server in SSE mode and pass in the URL: 47 | 48 | ```json 49 | { 50 | "mcpServers": { 51 | "redis-memory-server": { 52 | "url": "http://localhost:9000/sse" 53 | } 54 | } 55 | } 56 | ``` 57 | -------------------------------------------------------------------------------- /docs/memory-types.md: -------------------------------------------------------------------------------- 1 | # Memory Types 2 | 3 | The Redis Agent Memory Server provides two distinct types of memory storage, each optimized for different use cases and access patterns: **Working Memory** and **Long-Term Memory**. 4 | 5 | ## Overview 6 | 7 | | Feature | Working Memory | Long-Term Memory | 8 | |---------|----------------|------------------| 9 | | **Scope** | Session-scoped | Cross-session, persistent | 10 | | **Lifespan** | TTL-based (1 hour default) | Permanent until manually deleted | 11 | | **Storage** | Redis key-value with JSON | Redis with vector indexing | 12 | | **Search** | Simple text matching | Semantic vector search | 13 | | **Capacity** | Limited by window size | Unlimited (with compaction) | 14 | | **Use Case** | Active conversation state | Knowledge base, user preferences | 15 | | **Indexing** | None | Vector embeddings + metadata | 16 | | **Deduplication** | None | Hash-based and semantic | 17 | 18 | ## Working Memory 19 | 20 | Working memory is **session-scoped**, **ephemeral** storage designed for active conversation state and temporary data. It's the "scratch pad" where an AI agent keeps track of the current conversation context. 21 | 22 | ### Characteristics 23 | 24 | - **Session Scoped**: Each session has its own isolated working memory 25 | - **TTL-Based**: Automatically expires (default: 1 hour) 26 | - **Window Management**: Automatically summarizes when message count exceeds limits 27 | - **Mixed Content**: Stores both conversation messages and structured memory records 28 | - **No Indexing**: Simple JSON storage in Redis 29 | - **Promotion**: Structured memories can be promoted to long-term storage 30 | 31 | ### Data Structure 32 | 33 | Working memory contains: 34 | 35 | - **Messages**: Conversation history (role/content pairs) 36 | - **Memories**: Structured memory records awaiting promotion 37 | - **Context**: Summary of past conversation when truncated 38 | - **Data**: Arbitrary JSON key-value storage 39 | - **Metadata**: User ID, timestamps, TTL settings 40 | 41 | ### When to Use Working Memory 42 | 43 | 1. **Active Conversation State** 44 | ```python 45 | import ulid 46 | 47 | # Store current conversation messages 48 | working_memory = WorkingMemory( 49 | session_id="chat_123", 50 | messages=[ 51 | MemoryMessage(role="user", content="What's the weather like?", id=ulid.ULID()), 52 | MemoryMessage(role="assistant", content="I'll check that for you...", id=ulid.ULID()) 53 | ] 54 | ) 55 | ``` 56 | 57 | 2. **Temporary Structured Data** 58 | ```python 59 | # Store temporary facts during conversation 60 | working_memory = WorkingMemory( 61 | session_id="chat_123", 62 | memories=[ 63 | MemoryRecord( 64 | text="User is planning a trip to Paris next month", 65 | id="temp_trip_info", 66 | memory_type="episodic" 67 | ) 68 | ] 69 | ) 70 | ``` 71 | 72 | 3. **Session-Specific Settings** 73 | ```python 74 | # Store ephemeral configuration 75 | working_memory = WorkingMemory( 76 | session_id="chat_123", 77 | data={ 78 | "user_preferences": {"temperature_unit": "celsius"}, 79 | "conversation_mode": "casual", 80 | "current_task": "trip_planning" 81 | } 82 | ) 83 | ``` 84 | 85 | ### API Endpoints 86 | 87 | ```http 88 | # Get working memory for a session 89 | GET /v1/working-memory/{session_id}?namespace=demo&window_size=50 90 | 91 | # Set working memory (replaces existing) 92 | PUT /v1/working-memory/{session_id} 93 | 94 | # Delete working memory 95 | DELETE /v1/working-memory/{session_id}?namespace=demo 96 | ``` 97 | 98 | ### Automatic Promotion 99 | 100 | When structured memories in working memory are stored, they are automatically promoted to long-term storage in the background: 101 | 102 | 1. Memories with `persisted_at=null` are identified 103 | 2. Server assigns unique IDs and timestamps 104 | 3. Memories are indexed in long-term storage with vector embeddings 105 | 4. Working memory is updated with `persisted_at` timestamps 106 | 107 | ## Long-Term Memory 108 | 109 | Long-term memory is **persistent**, **cross-session** storage designed for knowledge that should be retained and searchable across all interactions. It's the "knowledge base" where important facts, preferences, and experiences are stored. 110 | 111 | ### Characteristics 112 | 113 | - **Cross-Session**: Accessible from any session 114 | - **Persistent**: Survives server restarts and session expiration 115 | - **Vector Indexed**: Semantic search with OpenAI embeddings 116 | - **Deduplication**: Automatic hash-based and semantic deduplication 117 | - **Rich Metadata**: Topics, entities, timestamps, memory types 118 | - **Compaction**: Automatic cleanup and merging of duplicates 119 | 120 | ### Memory Types 121 | 122 | Long-term memory supports three types of memories: 123 | 124 | 1. **Semantic**: Facts, preferences, general knowledge 125 | ```json 126 | { 127 | "text": "User prefers dark mode interfaces", 128 | "memory_type": "semantic", 129 | "topics": ["preferences", "ui"], 130 | "entities": ["dark mode"] 131 | } 132 | ``` 133 | 134 | 2. **Episodic**: Events with temporal context 135 | ```json 136 | { 137 | "text": "User visited Paris in March 2024", 138 | "memory_type": "episodic", 139 | "event_date": "2024-03-15T10:00:00Z", 140 | "topics": ["travel"], 141 | "entities": ["Paris"] 142 | } 143 | ``` 144 | 145 | 3. **Message**: Conversation records (auto-generated) 146 | ```json 147 | { 148 | "text": "user: What's the weather like?", 149 | "memory_type": "message", 150 | "session_id": "chat_123" 151 | } 152 | ``` 153 | 154 | ### When to Use Long-Term Memory 155 | 156 | 1. **User Preferences and Profile** 157 | ```python 158 | # Store lasting user preferences 159 | memories = [ 160 | MemoryRecord( 161 | text="User prefers metric units for temperature", 162 | id="pref_metric_temp", 163 | memory_type="semantic", 164 | topics=["preferences", "units"], 165 | user_id="user_123" 166 | ) 167 | ] 168 | ``` 169 | 170 | 2. **Important Facts and Knowledge** 171 | ```python 172 | # Store domain knowledge 173 | memories = [ 174 | MemoryRecord( 175 | text="Customer's subscription expires on 2024-06-15", 176 | id="sub_expiry_customer_456", 177 | memory_type="episodic", 178 | event_date=datetime(2024, 6, 15), 179 | entities=["customer_456", "subscription"], 180 | user_id="user_123" 181 | ) 182 | ] 183 | ``` 184 | 185 | 3. **Cross-Session Context** 186 | ```python 187 | # Store context that spans conversations 188 | memories = [ 189 | MemoryRecord( 190 | text="User is working on a Python machine learning project", 191 | id="context_ml_project", 192 | memory_type="semantic", 193 | topics=["programming", "machine-learning", "python"], 194 | namespace="work_context" 195 | ) 196 | ] 197 | ``` 198 | 199 | ### API Endpoints 200 | 201 | ```http 202 | # Create long-term memories 203 | POST /v1/long-term-memory/ 204 | 205 | # Search long-term memories only 206 | POST /v1/long-term-memory/search 207 | 208 | # Search across all memory types 209 | POST /v1/memory/search 210 | ``` 211 | 212 | ### Search Capabilities 213 | 214 | Long-term memory provides powerful search features: 215 | 216 | #### Semantic Vector Search 217 | ```json 218 | { 219 | "text": "python programming help", 220 | "limit": 10, 221 | "distance_threshold": 0.8 222 | } 223 | ``` 224 | 225 | #### Advanced Filtering 226 | ```json 227 | { 228 | "text": "user preferences", 229 | "filters": { 230 | "user_id": {"eq": "user_123"}, 231 | "memory_type": {"eq": "semantic"}, 232 | "topics": {"any": ["preferences", "settings"]}, 233 | "created_at": {"gte": "2024-01-01T00:00:00Z"} 234 | } 235 | } 236 | ``` 237 | 238 | #### Hybrid Search 239 | ```json 240 | { 241 | "text": "travel plans", 242 | "filters": { 243 | "namespace": {"eq": "personal"}, 244 | "event_date": {"gte": "2024-03-01T00:00:00Z"} 245 | }, 246 | "include_working_memory": true, 247 | "include_long_term_memory": true 248 | } 249 | ``` 250 | 251 | ## Memory Lifecycle 252 | 253 | ### 1. Creation in Working Memory 254 | ```python 255 | # Client creates structured memory 256 | memory = MemoryRecord( 257 | text="User likes Italian food", 258 | id="client_generated_id", 259 | memory_type="semantic" 260 | ) 261 | 262 | # Add to working memory 263 | working_memory = WorkingMemory( 264 | session_id="current_session", 265 | memories=[memory] 266 | ) 267 | ``` 268 | 269 | ### 2. Automatic Promotion 270 | ```python 271 | # Server promotes to long-term storage (background) 272 | # - Assigns persisted_at timestamp 273 | # - Generates vector embeddings 274 | # - Indexes for search 275 | # - Updates working memory with timestamps 276 | ``` 277 | 278 | ### 3. Deduplication and Compaction 279 | ```python 280 | # Server automatically: 281 | # - Identifies hash-based duplicates 282 | # - Finds semantically similar memories 283 | # - Merges related memories using LLM 284 | # - Removes obsolete duplicates 285 | ``` 286 | 287 | ### 4. Retrieval and Search 288 | ```python 289 | # Client searches across all memory 290 | results = await search_memories( 291 | text="food preferences", 292 | filters={"user_id": {"eq": "user_123"}} 293 | ) 294 | ``` 295 | 296 | ## Memory Prompt Integration 297 | 298 | The memory system integrates with AI prompts through the `/v1/memory/prompt` endpoint: 299 | 300 | ```python 301 | # Get memory-enriched prompt 302 | response = await memory_prompt({ 303 | "query": "Help me plan dinner", 304 | "session": { 305 | "session_id": "current_chat", 306 | "window_size": 20 307 | }, 308 | "long_term_search": { 309 | "text": "food preferences dietary restrictions", 310 | "filters": {"user_id": {"eq": "user_123"}}, 311 | "limit": 5 312 | } 313 | }) 314 | 315 | # Returns ready-to-use messages with: 316 | # - Conversation context from working memory 317 | # - Relevant memories from long-term storage 318 | # - User's query as final message 319 | ``` 320 | 321 | ## Best Practices 322 | 323 | ### Working Memory 324 | - Keep conversation state and temporary data 325 | - Use for session-specific configuration 326 | - Store structured memories that might become long-term 327 | - Let automatic promotion handle persistence 328 | 329 | ### Long-Term Memory 330 | - Store user preferences and lasting facts 331 | - Include rich metadata (topics, entities, timestamps) 332 | - Use meaningful IDs for easier retrieval 333 | - Leverage semantic search for discovery 334 | 335 | ### Memory Design 336 | - Use semantic memory for timeless facts 337 | - Use episodic memory for time-bound events 338 | - Include relevant topics and entities for better search 339 | - Design memory text for LLM consumption 340 | 341 | ### Search Strategy 342 | - Start with semantic search for discovery 343 | - Add filters for precision 344 | - Use unified search for comprehensive results 345 | - Consider both working and long-term contexts 346 | 347 | ## Configuration 348 | 349 | Memory behavior can be configured through environment variables: 350 | 351 | ```bash 352 | # Working memory settings 353 | WINDOW_SIZE=50 # Message window before summarization 354 | LONG_TERM_MEMORY=true # Enable long-term memory features 355 | 356 | # Long-term memory settings 357 | ENABLE_DISCRETE_MEMORY_EXTRACTION=true # Extract memories from messages 358 | GENERATION_MODEL=gpt-4o-mini # Model for summarization/extraction 359 | 360 | # Search settings 361 | DEFAULT_MEMORY_LIMIT=1000 # Default search result limit 362 | ``` 363 | 364 | For complete configuration options, see the [Configuration Guide](configuration.md). 365 | -------------------------------------------------------------------------------- /manual_oauth_qa/TROUBLESHOOTING.md: -------------------------------------------------------------------------------- 1 | # Auth0 Troubleshooting Guide 2 | 3 | This guide helps you diagnose and fix common Auth0 authentication issues. 4 | 5 | ## 🚨 Common Error Messages 6 | 7 | ### 403 Forbidden - "access_denied" 8 | 9 | **Error**: `Service not enabled within domain: https://your-api.com` 10 | 11 | **Cause**: Your Auth0 application is not authorized for the specified audience. 12 | 13 | **Solution**: 14 | 1. Go to Auth0 Dashboard → **APIs** → Your API 15 | 2. Click **Machine to Machine Applications** tab 16 | 3. Find your application and toggle it **ON** 17 | 4. Save the changes 18 | 19 | ### 401 Unauthorized - "Invalid token header" 20 | 21 | **Error**: `Invalid token header` 22 | 23 | **Cause**: Token is malformed or missing. 24 | 25 | **Solutions**: 26 | 1. Check that `Authorization: Bearer ` header is included 27 | 2. Verify the token is not expired 28 | 3. Ensure no extra spaces or characters in the token 29 | 30 | ### 401 Unauthorized - "Invalid audience" 31 | 32 | **Error**: `Invalid audience. Expected: https://your-api.com` 33 | 34 | **Cause**: Token audience doesn't match server configuration. 35 | 36 | **Solution**: 37 | 1. Check `OAUTH2_AUDIENCE` in your `.env` file 38 | 2. Ensure it matches your Auth0 API identifier exactly 39 | 3. Verify your Auth0 application is authorized for this API 40 | 41 | ### Connection Refused 42 | 43 | **Error**: `Connection refused` when testing endpoints 44 | 45 | **Cause**: Memory server is not running. 46 | 47 | **Solution**: 48 | ```bash 49 | # Start the memory server 50 | uv run python -m agent_memory_server.main 51 | ``` 52 | 53 | ## 🔧 Debugging Steps 54 | 55 | ### Step 1: Check Environment Variables 56 | 57 | ```bash 58 | # Check for conflicting environment variables 59 | env | grep -E "(OAUTH2|AUTH0)" 60 | 61 | # If you see conflicting values, unset them: 62 | unset OAUTH2_AUDIENCE 63 | unset OAUTH2_ISSUER_URL 64 | ``` 65 | 66 | ### Step 2: Verify Auth0 Configuration 67 | 68 | ```bash 69 | # Run the debug script 70 | python manual_oauth_qa/debug_auth0.py 71 | ``` 72 | 73 | Expected output: 74 | ``` 75 | ✅ All required values present 76 | ✅ Auth0 token request successful! 77 | ``` 78 | 79 | ### Step 3: Test Auth0 Token Manually 80 | 81 | ```bash 82 | # Replace with your actual values 83 | curl -X POST "https://your-domain.auth0.com/oauth/token" \ 84 | -H "Content-Type: application/json" \ 85 | -d '{ 86 | "client_id": "your-client-id", 87 | "client_secret": "your-client-secret", 88 | "audience": "https://api.redis-memory-server.com", 89 | "grant_type": "client_credentials" 90 | }' 91 | ``` 92 | 93 | ### Step 4: Check Server Logs 94 | 95 | Look for these messages when starting the server: 96 | 97 | ``` 98 | ✅ Good: OAuth2 authentication configured 99 | ❌ Bad: Authentication is DISABLED 100 | ❌ Bad: OAuth2 issuer URL not configured 101 | ``` 102 | 103 | ### Step 5: Verify API Authorization 104 | 105 | 1. Go to Auth0 Dashboard 106 | 2. Navigate to **APIs** → Your API → **Machine to Machine Applications** 107 | 3. Ensure your application is **authorized** (toggle ON) 108 | 4. Check that required scopes are selected 109 | 110 | ## 🐛 Specific Issues 111 | 112 | ### Issue: "Service not enabled within domain" 113 | 114 | This happens when using the wrong audience or unauthorized application. 115 | 116 | **Fix**: 117 | 1. Create a custom API in Auth0 (not Management API) 118 | 2. Use the custom API identifier as your audience 119 | 3. Authorize your application for the custom API 120 | 121 | ### Issue: Environment Variables Not Loading 122 | 123 | **Symptoms**: Configuration looks correct but still fails 124 | 125 | **Fix**: 126 | ```bash 127 | # Check if shell environment is overriding .env 128 | env | grep OAUTH2_AUDIENCE 129 | 130 | # If found, unset it 131 | unset OAUTH2_AUDIENCE 132 | 133 | # Restart your shell or reload .env 134 | source .env 135 | ``` 136 | 137 | ### Issue: Token Expires Immediately 138 | 139 | **Symptoms**: Token works once then fails 140 | 141 | **Cause**: System clock is wrong or token caching issue 142 | 143 | **Fix**: 144 | 1. Check system time: `date` 145 | 2. Sync time if needed 146 | 3. Clear any token caches 147 | 148 | ### Issue: JWKS Key Not Found 149 | 150 | **Error**: `Unable to find matching public key for kid: xyz` 151 | 152 | **Cause**: Auth0 key rotation or network issues 153 | 154 | **Fix**: 155 | 1. Wait a few minutes for key propagation 156 | 2. Check network connectivity to Auth0 157 | 3. Verify JWKS URL is accessible 158 | 159 | ## 📋 Checklist for New Machine Setup 160 | 161 | - [ ] Python 3.8+ installed 162 | - [ ] All dependencies installed (`pip install -r requirements.txt`) 163 | - [ ] Redis server running (`redis-server`) 164 | - [ ] `.env` file created and configured 165 | - [ ] Auth0 application created and configured 166 | - [ ] Auth0 API created and application authorized 167 | - [ ] No conflicting environment variables 168 | - [ ] Memory server starts without errors 169 | - [ ] Auth0 token request succeeds 170 | 171 | ## 🆘 Getting Help 172 | 173 | If you're still having issues: 174 | 175 | 1. **Run diagnostics**: 176 | ```bash 177 | python manual_oauth_qa/setup_check.py 178 | python manual_oauth_qa/debug_auth0.py 179 | ``` 180 | 181 | 2. **Check Auth0 logs**: 182 | - Go to Auth0 Dashboard → **Monitoring** → **Logs** 183 | - Look for failed authentication attempts 184 | 185 | 3. **Verify network connectivity**: 186 | ```bash 187 | curl -I https://your-domain.auth0.com/.well-known/jwks.json 188 | ``` 189 | 190 | 4. **Test with minimal example**: 191 | Use the debug script to isolate the issue 192 | 193 | ## 📞 Support Resources 194 | 195 | - [Auth0 Documentation](https://auth0.com/docs) 196 | - [Auth0 Community](https://community.auth0.com/) 197 | - [Auth0 Support](https://support.auth0.com/) 198 | - [JWT.io](https://jwt.io/) - For debugging JWT tokens 199 | -------------------------------------------------------------------------------- /manual_oauth_qa/debug_auth0.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | 4 | import httpx 5 | from dotenv import load_dotenv 6 | 7 | 8 | load_dotenv() 9 | 10 | AUTH0_DOMAIN = ( 11 | os.getenv("OAUTH2_ISSUER_URL", "").replace("https://", "").replace("/", "") 12 | ) 13 | AUTH0_CLIENT_ID = os.getenv("AUTH0_CLIENT_ID") 14 | AUTH0_CLIENT_SECRET = os.getenv("AUTH0_CLIENT_SECRET") 15 | AUTH0_AUDIENCE = os.getenv("OAUTH2_AUDIENCE") 16 | 17 | print("=== Auth0 Configuration Debug ===") 18 | print(f"Domain: {AUTH0_DOMAIN}") 19 | print(f"Client ID: {AUTH0_CLIENT_ID}") 20 | print( 21 | f"Client Secret: {AUTH0_CLIENT_SECRET[:10]}..." if AUTH0_CLIENT_SECRET else "None" 22 | ) 23 | print(f"Audience: {AUTH0_AUDIENCE}") 24 | print(f"Token URL: https://{AUTH0_DOMAIN}/oauth/token") 25 | 26 | print("\n=== Validation ===") 27 | missing = [] 28 | if not AUTH0_DOMAIN: 29 | missing.append("OAUTH2_ISSUER_URL") 30 | if not AUTH0_CLIENT_ID: 31 | missing.append("AUTH0_CLIENT_ID") 32 | if not AUTH0_CLIENT_SECRET: 33 | missing.append("AUTH0_CLIENT_SECRET") 34 | if not AUTH0_AUDIENCE: 35 | missing.append("OAUTH2_AUDIENCE") 36 | 37 | if missing: 38 | print(f"❌ Missing: {', '.join(missing)}") 39 | exit(1) 40 | else: 41 | print("✅ All required values present") 42 | 43 | print("\n=== Testing Auth0 Token Request ===") 44 | token_url = f"https://{AUTH0_DOMAIN}/oauth/token" 45 | payload = { 46 | "client_id": AUTH0_CLIENT_ID, 47 | "client_secret": AUTH0_CLIENT_SECRET, 48 | "audience": AUTH0_AUDIENCE, 49 | "grant_type": "client_credentials", 50 | } 51 | 52 | print(f"Request URL: {token_url}") 53 | print(f"Request payload: {payload}") 54 | 55 | try: 56 | with httpx.Client(timeout=10.0) as client: 57 | response = client.post( 58 | token_url, json=payload, headers={"Content-Type": "application/json"} 59 | ) 60 | print(f"Response status: {response.status_code}") 61 | print(f"Response: {response.text}") 62 | 63 | if response.status_code == 200: 64 | print("✅ Auth0 token request successful!") 65 | else: 66 | print("❌ Auth0 token request failed!") 67 | 68 | except Exception as e: 69 | print(f"❌ Exception during token request: {e}") 70 | -------------------------------------------------------------------------------- /manual_oauth_qa/env_template: -------------------------------------------------------------------------------- 1 | # Auth0 Manual Testing Configuration Template 2 | # Copy this file to .env and update with your actual values 3 | 4 | # Redis Configuration 5 | REDIS_URL=redis://localhost:6379 6 | 7 | # Authentication Configuration (Auth0) 8 | DISABLE_AUTH=false 9 | OAUTH2_ISSUER_URL=https://your-domain.auth0.com/ 10 | OAUTH2_AUDIENCE=https://api.redis-memory-server.com 11 | OAUTH2_ALGORITHMS=["RS256"] 12 | 13 | # Auth0 Client Credentials (get these from Auth0 Dashboard) 14 | AUTH0_CLIENT_ID=your-auth0-client-id 15 | AUTH0_CLIENT_SECRET=your-auth0-client-secret 16 | 17 | # Optional: Explicit JWKS URL (auto-derived from issuer if not set) 18 | # OAUTH2_JWKS_URL=https://your-domain.auth0.com/.well-known/jwks.json 19 | 20 | # AI Service API Keys 21 | OPENAI_API_KEY=your-openai-api-key-here 22 | ANTHROPIC_API_KEY=your-anthropic-api-key-here 23 | 24 | # Model Configuration 25 | GENERATION_MODEL=gpt-4o-mini 26 | EMBEDDING_MODEL=text-embedding-3-small 27 | 28 | # Memory Configuration 29 | LONG_TERM_MEMORY=true 30 | WINDOW_SIZE=20 31 | ENABLE_TOPIC_EXTRACTION=true 32 | ENABLE_NER=true 33 | 34 | # Logging 35 | LOG_LEVEL=DEBUG 36 | 37 | # Server Configuration 38 | PORT=8000 39 | MCP_PORT=9000 40 | 41 | # Background Tasks 42 | USE_DOCKET=true 43 | -------------------------------------------------------------------------------- /manual_oauth_qa/manual_auth0_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Manual Auth0 Testing Script for Redis Memory Server 4 | 5 | This script helps you test Auth0 authentication with the Redis Memory Server. 6 | It will: 7 | 1. Get an access token from Auth0 8 | 2. Test various API endpoints with the token 9 | 3. Verify authentication is working correctly 10 | 11 | Prerequisites: 12 | 1. Auth0 application configured (Machine to Machine) 13 | 2. .env file with Auth0 configuration 14 | 3. Redis server running 15 | 4. Memory server running with authentication enabled 16 | """ 17 | 18 | import os 19 | import sys 20 | import time 21 | from typing import Any 22 | 23 | import httpx 24 | import structlog 25 | from dotenv import load_dotenv 26 | 27 | 28 | # Load environment variables 29 | load_dotenv() 30 | 31 | # Configure logging 32 | logger = structlog.get_logger() 33 | 34 | # Auth0 Configuration 35 | AUTH0_DOMAIN = ( 36 | os.getenv("OAUTH2_ISSUER_URL", "").replace("https://", "").replace("/", "") 37 | ) 38 | AUTH0_CLIENT_ID = os.getenv("AUTH0_CLIENT_ID") 39 | AUTH0_CLIENT_SECRET = os.getenv("AUTH0_CLIENT_SECRET") 40 | AUTH0_AUDIENCE = os.getenv("OAUTH2_AUDIENCE") 41 | 42 | # Memory Server Configuration 43 | MEMORY_SERVER_URL = f"http://localhost:{os.getenv('PORT', '8000')}" 44 | 45 | 46 | class Auth0Tester: 47 | def __init__(self): 48 | self.access_token = None 49 | self.client = httpx.Client(timeout=30.0) 50 | 51 | def get_auth0_token(self) -> str: 52 | """Get an access token from Auth0""" 53 | if not all( 54 | [AUTH0_DOMAIN, AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, AUTH0_AUDIENCE] 55 | ): 56 | raise ValueError( 57 | "Missing Auth0 configuration. Please set:\n" 58 | "- OAUTH2_ISSUER_URL (e.g., https://your-domain.auth0.com/)\n" 59 | "- AUTH0_CLIENT_ID\n" 60 | "- AUTH0_CLIENT_SECRET\n" 61 | "- OAUTH2_AUDIENCE" 62 | ) 63 | 64 | token_url = f"https://{AUTH0_DOMAIN}/oauth/token" 65 | 66 | payload = { 67 | "client_id": AUTH0_CLIENT_ID, 68 | "client_secret": AUTH0_CLIENT_SECRET, 69 | "audience": AUTH0_AUDIENCE, 70 | "grant_type": "client_credentials", 71 | } 72 | 73 | headers = {"Content-Type": "application/json"} 74 | 75 | logger.info( 76 | "Requesting Auth0 access token", 77 | domain=AUTH0_DOMAIN, 78 | audience=AUTH0_AUDIENCE, 79 | ) 80 | 81 | try: 82 | response = self.client.post(token_url, json=payload, headers=headers) 83 | response.raise_for_status() 84 | 85 | token_data = response.json() 86 | self.access_token = token_data["access_token"] 87 | 88 | logger.info( 89 | "Successfully obtained Auth0 token", 90 | token_type=token_data.get("token_type"), 91 | expires_in=token_data.get("expires_in"), 92 | ) 93 | 94 | return self.access_token 95 | 96 | except httpx.HTTPError as e: 97 | logger.error("Failed to get Auth0 token", error=str(e)) 98 | if hasattr(e, "response") and e.response: 99 | logger.error("Auth0 error response", response=e.response.text) 100 | raise 101 | 102 | def test_endpoint( 103 | self, method: str, endpoint: str, data: dict[str, Any] = None 104 | ) -> dict[str, Any]: 105 | """Test a memory server endpoint with authentication""" 106 | url = f"{MEMORY_SERVER_URL}{endpoint}" 107 | headers = { 108 | "Authorization": f"Bearer {self.access_token}", 109 | "Content-Type": "application/json", 110 | } 111 | 112 | logger.info(f"Testing {method} {endpoint}") 113 | 114 | try: 115 | if method.upper() == "GET": 116 | response = self.client.get(url, headers=headers) 117 | elif method.upper() == "POST": 118 | response = self.client.post(url, headers=headers, json=data or {}) 119 | elif method.upper() == "PUT": 120 | response = self.client.put(url, headers=headers, json=data or {}) 121 | elif method.upper() == "DELETE": 122 | response = self.client.delete(url, headers=headers) 123 | else: 124 | raise ValueError(f"Unsupported method: {method}") 125 | 126 | result = { 127 | "status_code": response.status_code, 128 | "success": response.status_code < 400, 129 | "response": response.json() 130 | if response.headers.get("content-type", "").startswith( 131 | "application/json" 132 | ) 133 | else response.text, 134 | } 135 | 136 | if result["success"]: 137 | logger.info( 138 | f"✅ {method} {endpoint} - Success", status=response.status_code 139 | ) 140 | else: 141 | logger.error( 142 | f"❌ {method} {endpoint} - Failed", 143 | status=response.status_code, 144 | response=result["response"], 145 | ) 146 | 147 | return result 148 | 149 | except Exception as e: 150 | logger.error(f"❌ {method} {endpoint} - Exception", error=str(e)) 151 | return {"status_code": 0, "success": False, "error": str(e)} 152 | 153 | def run_comprehensive_test(self): 154 | """Run a comprehensive test of all endpoints""" 155 | logger.info("🚀 Starting comprehensive Auth0 authentication test") 156 | 157 | # Step 1: Get Auth0 token 158 | try: 159 | self.get_auth0_token() 160 | except Exception as e: 161 | logger.error("Failed to get Auth0 token, aborting tests", error=str(e)) 162 | return False 163 | 164 | # Step 2: Test health endpoint (should work without auth) 165 | logger.info("\n📋 Testing health endpoint (no auth required)") 166 | health_result = self.test_endpoint("GET", "/health") 167 | 168 | # Step 3: Test authenticated endpoints 169 | logger.info("\n🔐 Testing authenticated endpoints") 170 | 171 | test_cases = [ 172 | # Sessions endpoints 173 | ("GET", "/sessions/", None), 174 | # Memory endpoints 175 | ( 176 | "POST", 177 | "/memory-prompt", 178 | { 179 | "query": "What is the capital of France?", 180 | "session": { 181 | "session_id": "test-session-auth0", 182 | "namespace": "test-auth0", 183 | "window_size": 10, 184 | }, 185 | }, 186 | ), 187 | ( 188 | "POST", 189 | "/long-term-memory", 190 | { 191 | "memories": [ 192 | { 193 | "id": "auth0-test-memory-1", 194 | "text": "Auth0 test memory", 195 | "session_id": "test-session-auth0", 196 | "namespace": "test-auth0", 197 | } 198 | ] 199 | }, 200 | ), 201 | ("POST", "/long-term-memory/search", {"text": "Auth0 test", "limit": 5}), 202 | ] 203 | 204 | results = [] 205 | for method, endpoint, data in test_cases: 206 | result = self.test_endpoint(method, endpoint, data) 207 | results.append((method, endpoint, result)) 208 | time.sleep(0.5) # Small delay between requests 209 | 210 | # Step 4: Test without token (should fail) 211 | logger.info("\n🚫 Testing without authentication (should fail)") 212 | old_token = self.access_token 213 | self.access_token = None 214 | 215 | no_auth_result = self.test_endpoint("GET", "/sessions/") 216 | expected_failure = no_auth_result["status_code"] == 401 217 | 218 | if expected_failure: 219 | logger.info("✅ Correctly rejected request without authentication") 220 | else: 221 | logger.error( 222 | "❌ Request without authentication should have failed with 401" 223 | ) 224 | 225 | # Restore token 226 | self.access_token = old_token 227 | 228 | # Step 5: Test with invalid token (should fail) 229 | logger.info("\n🚫 Testing with invalid token (should fail)") 230 | self.access_token = "invalid.jwt.token" 231 | 232 | invalid_token_result = self.test_endpoint("GET", "/sessions/") 233 | expected_invalid_failure = invalid_token_result["status_code"] == 401 234 | 235 | if expected_invalid_failure: 236 | logger.info("✅ Correctly rejected request with invalid token") 237 | else: 238 | logger.error("❌ Request with invalid token should have failed with 401") 239 | 240 | # Restore token 241 | self.access_token = old_token 242 | 243 | # Step 6: Summary 244 | logger.info("\n📊 Test Summary") 245 | successful_tests = sum(1 for _, _, result in results if result["success"]) 246 | total_tests = len(results) 247 | 248 | logger.info(f"Authenticated endpoints: {successful_tests}/{total_tests} passed") 249 | logger.info(f"Health endpoint: {'✅' if health_result['success'] else '❌'}") 250 | logger.info(f"No auth rejection: {'✅' if expected_failure else '❌'}") 251 | logger.info( 252 | f"Invalid token rejection: {'✅' if expected_invalid_failure else '❌'}" 253 | ) 254 | 255 | overall_success = ( 256 | successful_tests == total_tests 257 | and health_result["success"] 258 | and expected_failure 259 | and expected_invalid_failure 260 | ) 261 | 262 | if overall_success: 263 | logger.info("🎉 All Auth0 authentication tests passed!") 264 | else: 265 | logger.error("❌ Some Auth0 authentication tests failed") 266 | 267 | return overall_success 268 | 269 | 270 | def main(): 271 | """Main function to run Auth0 tests""" 272 | print("🔮 Redis Memory Server - Auth0 Manual Testing") 273 | print("=" * 50) 274 | 275 | # Check if memory server is running 276 | try: 277 | response = httpx.get(f"{MEMORY_SERVER_URL}/health", timeout=5.0) 278 | if response.status_code != 200: 279 | print(f"❌ Memory server not responding correctly at {MEMORY_SERVER_URL}") 280 | print("Please start the memory server first:") 281 | print(" uv run python -m agent_memory_server.main") 282 | sys.exit(1) 283 | except Exception as e: 284 | print(f"❌ Cannot connect to memory server at {MEMORY_SERVER_URL}") 285 | print(f"Error: {e}") 286 | print("Please start the memory server first:") 287 | print(" uv run python -m agent_memory_server.main") 288 | sys.exit(1) 289 | 290 | print(f"✅ Memory server is running at {MEMORY_SERVER_URL}") 291 | 292 | # Run tests 293 | tester = Auth0Tester() 294 | success = tester.run_comprehensive_test() 295 | 296 | sys.exit(0 if success else 1) 297 | 298 | 299 | if __name__ == "__main__": 300 | main() 301 | -------------------------------------------------------------------------------- /manual_oauth_qa/quick_auth0_setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Quick Auth0 Setup Script for Redis Memory Server 4 | # This script helps you quickly set up and test Auth0 authentication 5 | 6 | set -e 7 | 8 | echo "🔮 Redis Memory Server - Auth0 Quick Setup" 9 | echo "==========================================" 10 | 11 | # Check if .env exists 12 | if [ ! -f .env ]; then 13 | echo "📝 Creating .env file from template..." 14 | cp manual_oauth_qa/env_template .env 15 | echo "✅ Created .env file" 16 | echo "" 17 | echo "⚠️ IMPORTANT: Please edit .env and update the following values:" 18 | echo " - OAUTH2_ISSUER_URL (your Auth0 domain)" 19 | echo " - OAUTH2_AUDIENCE (your Auth0 API identifier)" 20 | echo " - AUTH0_CLIENT_ID (your Auth0 application client ID)" 21 | echo " - AUTH0_CLIENT_SECRET (your Auth0 application client secret)" 22 | echo " - OPENAI_API_KEY (your OpenAI API key)" 23 | echo " - ANTHROPIC_API_KEY (your Anthropic API key)" 24 | echo "" 25 | read -p "Press Enter after you've updated .env..." 26 | else 27 | echo "✅ .env file already exists" 28 | fi 29 | 30 | # Check if Redis is running 31 | echo "🔍 Checking Redis connection..." 32 | if redis-cli ping > /dev/null 2>&1; then 33 | echo "✅ Redis is running" 34 | else 35 | echo "❌ Redis is not running. Starting Redis with Docker..." 36 | if command -v docker > /dev/null 2>&1; then 37 | docker run -d -p 6379:6379 --name redis-memory-test redis/redis-stack-server:latest 38 | echo "✅ Started Redis container" 39 | sleep 2 40 | else 41 | echo "❌ Docker not found. Please start Redis manually:" 42 | echo " brew install redis && brew services start redis" 43 | echo " OR" 44 | echo " docker run -d -p 6379:6379 redis/redis-stack-server:latest" 45 | exit 1 46 | fi 47 | fi 48 | 49 | # Check environment variables 50 | echo "🔍 Checking Auth0 configuration..." 51 | source .env 52 | 53 | if [ -z "$OAUTH2_ISSUER_URL" ] || [ "$OAUTH2_ISSUER_URL" = "https://your-domain.auth0.com/" ]; then 54 | echo "❌ OAUTH2_ISSUER_URL not configured in .env" 55 | exit 1 56 | fi 57 | 58 | if [ -z "$OAUTH2_AUDIENCE" ] || [ "$OAUTH2_AUDIENCE" = "https://api.your-app.com" ]; then 59 | echo "❌ OAUTH2_AUDIENCE not configured in .env" 60 | exit 1 61 | fi 62 | 63 | if [ -z "$AUTH0_CLIENT_ID" ] || [ "$AUTH0_CLIENT_ID" = "your-client-id" ]; then 64 | echo "❌ AUTH0_CLIENT_ID not configured in .env" 65 | exit 1 66 | fi 67 | 68 | if [ -z "$AUTH0_CLIENT_SECRET" ] || [ "$AUTH0_CLIENT_SECRET" = "your-client-secret" ]; then 69 | echo "❌ AUTH0_CLIENT_SECRET not configured in .env" 70 | exit 1 71 | fi 72 | 73 | echo "✅ Auth0 configuration looks good" 74 | 75 | # Test Auth0 token endpoint 76 | echo "🔍 Testing Auth0 token endpoint..." 77 | AUTH0_DOMAIN=$(echo $OAUTH2_ISSUER_URL | sed 's|https://||' | sed 's|/||') 78 | 79 | TOKEN_RESPONSE=$(curl -s -X POST "https://$AUTH0_DOMAIN/oauth/token" \ 80 | -H "Content-Type: application/json" \ 81 | -d "{ 82 | \"client_id\": \"$AUTH0_CLIENT_ID\", 83 | \"client_secret\": \"$AUTH0_CLIENT_SECRET\", 84 | \"audience\": \"$OAUTH2_AUDIENCE\", 85 | \"grant_type\": \"client_credentials\" 86 | }") 87 | 88 | if echo "$TOKEN_RESPONSE" | grep -q "access_token"; then 89 | echo "✅ Successfully obtained Auth0 token" 90 | else 91 | echo "❌ Failed to get Auth0 token:" 92 | echo "$TOKEN_RESPONSE" 93 | exit 1 94 | fi 95 | 96 | echo "" 97 | echo "🚀 Setup complete! You can now:" 98 | echo "" 99 | echo "1. Start the memory server:" 100 | echo " uv run python -m agent_memory_server.main" 101 | echo "" 102 | echo "2. Run the automated Auth0 test:" 103 | echo " uv run python manual_oauth_qa/manual_auth0_test.py" 104 | echo "" 105 | echo "3. Or follow the manual testing guide:" 106 | echo " cat manual_oauth_qa/README.md" 107 | echo "" 108 | echo " Happy testing!" 109 | -------------------------------------------------------------------------------- /manual_oauth_qa/quick_setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Quick Auth0 Setup Script for Redis Memory Server 4 | # This script helps you quickly set up and test Auth0 authentication 5 | 6 | set -e 7 | 8 | echo "🔮 Redis Memory Server - Auth0 Quick Setup" 9 | echo "==========================================" 10 | 11 | # Check if we're in the right directory 12 | if [ ! -f "pyproject.toml" ]; then 13 | echo "❌ Please run this script from the redis-memory-server root directory" 14 | exit 1 15 | fi 16 | 17 | # Check if .env exists 18 | if [ ! -f .env ]; then 19 | echo "📝 Creating .env file from template..." 20 | cp manual_oauth_qa/env_template .env 21 | echo "✅ Created .env file" 22 | echo "" 23 | echo "⚠️ IMPORTANT: Please edit .env and update the following values:" 24 | echo " - OAUTH2_ISSUER_URL (your Auth0 domain)" 25 | echo " - OAUTH2_AUDIENCE (your Auth0 API identifier)" 26 | echo " - AUTH0_CLIENT_ID (your Auth0 application client ID)" 27 | echo " - AUTH0_CLIENT_SECRET (your Auth0 application client secret)" 28 | echo " - OPENAI_API_KEY (your OpenAI API key)" 29 | echo " - ANTHROPIC_API_KEY (your Anthropic API key)" 30 | echo "" 31 | echo "Then run this script again to continue setup." 32 | exit 0 33 | else 34 | echo "✅ .env file already exists" 35 | fi 36 | 37 | # Check Redis connection 38 | echo "🔍 Checking Redis connection..." 39 | if redis-cli ping > /dev/null 2>&1; then 40 | echo "✅ Redis is running" 41 | else 42 | echo "❌ Redis is not running. Please start Redis:" 43 | echo " brew services start redis # macOS with Homebrew" 44 | echo " sudo systemctl start redis # Linux with systemd" 45 | echo " redis-server # Manual start" 46 | exit 1 47 | fi 48 | 49 | # Check Auth0 configuration 50 | echo "🔍 Checking Auth0 configuration..." 51 | source .env 52 | 53 | if [[ -z "$OAUTH2_ISSUER_URL" || "$OAUTH2_ISSUER_URL" == "https://your-domain.auth0.com/" ]]; then 54 | echo "❌ OAUTH2_ISSUER_URL not configured in .env" 55 | exit 1 56 | fi 57 | 58 | if [[ -z "$OAUTH2_AUDIENCE" || "$OAUTH2_AUDIENCE" == "https://api.redis-memory-server.com" ]]; then 59 | echo "❌ OAUTH2_AUDIENCE not configured in .env" 60 | exit 1 61 | fi 62 | 63 | if [[ -z "$AUTH0_CLIENT_ID" || "$AUTH0_CLIENT_ID" == "your-auth0-client-id" ]]; then 64 | echo "❌ AUTH0_CLIENT_ID not configured in .env" 65 | exit 1 66 | fi 67 | 68 | if [[ -z "$AUTH0_CLIENT_SECRET" || "$AUTH0_CLIENT_SECRET" == "your-auth0-client-secret" ]]; then 69 | echo "❌ AUTH0_CLIENT_SECRET not configured in .env" 70 | exit 1 71 | fi 72 | 73 | echo "✅ Auth0 configuration looks good" 74 | 75 | # Test Auth0 token endpoint 76 | echo "🔍 Testing Auth0 token endpoint..." 77 | DOMAIN=$(echo $OAUTH2_ISSUER_URL | sed 's|https://||' | sed 's|/$||') 78 | TOKEN_RESPONSE=$(curl -s -X POST "https://$DOMAIN/oauth/token" \ 79 | -H "Content-Type: application/json" \ 80 | -d "{ 81 | \"client_id\": \"$AUTH0_CLIENT_ID\", 82 | \"client_secret\": \"$AUTH0_CLIENT_SECRET\", 83 | \"audience\": \"$OAUTH2_AUDIENCE\", 84 | \"grant_type\": \"client_credentials\" 85 | }") 86 | 87 | if echo "$TOKEN_RESPONSE" | grep -q "access_token"; then 88 | echo "✅ Successfully obtained Auth0 token" 89 | else 90 | echo "❌ Failed to get Auth0 token:" 91 | echo "$TOKEN_RESPONSE" 92 | exit 1 93 | fi 94 | 95 | # Check if memory server is running 96 | echo "🔍 Checking memory server..." 97 | PORT=${PORT:-8000} 98 | if curl -s "http://localhost:$PORT/health" > /dev/null 2>&1; then 99 | echo "✅ Memory server is running on port $PORT" 100 | 101 | echo "" 102 | echo "🚀 Setup complete! You can now:" 103 | echo "" 104 | echo "1. Run the comprehensive Auth0 test:" 105 | echo " python manual_oauth_qa/test_auth0.py" 106 | echo "" 107 | echo "2. Run setup checks:" 108 | echo " python manual_oauth_qa/setup_check.py" 109 | echo "" 110 | echo "3. Debug Auth0 configuration:" 111 | echo " python manual_oauth_qa/debug_auth0.py" 112 | echo "" 113 | echo "🎉 Happy testing!" 114 | else 115 | echo "❌ Memory server is not running on port $PORT" 116 | echo "" 117 | echo "Start the memory server with:" 118 | echo " uv run python -m agent_memory_server.main" 119 | echo "" 120 | echo "Then run the Auth0 tests:" 121 | echo " python manual_oauth_qa/test_auth0.py" 122 | fi 123 | -------------------------------------------------------------------------------- /manual_oauth_qa/setup_check.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Setup Check Script for Auth0 Manual Testing 4 | 5 | This script verifies that all dependencies and configuration 6 | are properly set up for Auth0 testing. 7 | """ 8 | 9 | import os 10 | import sys 11 | from pathlib import Path 12 | 13 | 14 | def check_python_version(): 15 | """Check Python version""" 16 | print("🐍 Checking Python version...") 17 | version = sys.version_info 18 | if version.major >= 3 and version.minor >= 8: 19 | print(f"✅ Python {version.major}.{version.minor}.{version.micro} - OK") 20 | return True 21 | print( 22 | f"❌ Python {version.major}.{version.minor}.{version.micro} - Need Python 3.8+" 23 | ) 24 | return False 25 | 26 | 27 | def check_dependencies(): 28 | """Check required Python packages""" 29 | print("\n📦 Checking Python dependencies...") 30 | required_packages = [ 31 | "httpx", 32 | "structlog", 33 | "python-dotenv", 34 | "fastapi", 35 | "uvicorn", 36 | "redis", 37 | "pydantic", 38 | ] 39 | 40 | missing = [] 41 | for package in required_packages: 42 | try: 43 | __import__(package.replace("-", "_")) 44 | print(f"✅ {package}") 45 | except ImportError: 46 | print(f"❌ {package} - Missing") 47 | missing.append(package) 48 | 49 | if missing: 50 | print(f"\n❌ Missing packages: {', '.join(missing)}") 51 | print("Install with: pip install " + " ".join(missing)) 52 | return False 53 | print("✅ All required packages installed") 54 | return True 55 | 56 | 57 | def check_redis(): 58 | """Check Redis connection""" 59 | print("\n🔴 Checking Redis connection...") 60 | try: 61 | import redis 62 | 63 | r = redis.Redis(host="localhost", port=6379, decode_responses=True) 64 | r.ping() 65 | print("✅ Redis is running and accessible") 66 | return True 67 | except Exception as e: 68 | print(f"❌ Redis connection failed: {e}") 69 | print("Start Redis with: redis-server") 70 | return False 71 | 72 | 73 | def check_env_file(): 74 | """Check if .env file exists and has required variables""" 75 | print("\n📄 Checking .env file...") 76 | env_path = Path(".env") 77 | 78 | if not env_path.exists(): 79 | print("❌ .env file not found") 80 | print("Copy env_template to .env and configure it:") 81 | print("cp manual_oauth_qa/env_template .env") 82 | return False 83 | 84 | print("✅ .env file exists") 85 | 86 | # Check required variables 87 | from dotenv import load_dotenv 88 | 89 | load_dotenv() 90 | 91 | required_vars = [ 92 | "OAUTH2_ISSUER_URL", 93 | "OAUTH2_AUDIENCE", 94 | "AUTH0_CLIENT_ID", 95 | "AUTH0_CLIENT_SECRET", 96 | ] 97 | 98 | missing_vars = [] 99 | for var in required_vars: 100 | value = os.getenv(var) 101 | if not value or value.startswith("your-"): 102 | missing_vars.append(var) 103 | print(f"❌ {var} - Not configured") 104 | else: 105 | print(f"✅ {var} - Configured") 106 | 107 | if missing_vars: 108 | print(f"\n❌ Configure these variables in .env: {', '.join(missing_vars)}") 109 | return False 110 | print("✅ All required environment variables configured") 111 | return True 112 | 113 | 114 | def check_memory_server(): 115 | """Check if memory server is accessible""" 116 | print("\n🧠 Checking memory server...") 117 | try: 118 | import httpx 119 | 120 | port = os.getenv("PORT", "8000") 121 | response = httpx.get(f"http://localhost:{port}/health", timeout=5.0) 122 | if response.status_code == 200: 123 | print(f"✅ Memory server running on port {port}") 124 | return True 125 | print(f"❌ Memory server responded with status {response.status_code}") 126 | return False 127 | except Exception as e: 128 | print(f"❌ Memory server not accessible: {e}") 129 | print("Start the server with: uv run python -m agent_memory_server.main") 130 | return False 131 | 132 | 133 | def main(): 134 | """Run all checks""" 135 | print("🔍 Redis Memory Server - Auth0 Setup Check") 136 | print("=" * 50) 137 | 138 | checks = [ 139 | check_python_version(), 140 | check_dependencies(), 141 | check_redis(), 142 | check_env_file(), 143 | check_memory_server(), 144 | ] 145 | 146 | passed = sum(checks) 147 | total = len(checks) 148 | 149 | print(f"\n📊 Setup Check Results: {passed}/{total} passed") 150 | 151 | if passed == total: 152 | print("🎉 All checks passed! Ready for Auth0 testing.") 153 | print("\nNext steps:") 154 | print("1. Run: python manual_oauth_qa/test_auth0.py") 155 | return True 156 | print("❌ Some checks failed. Please fix the issues above.") 157 | return False 158 | 159 | 160 | if __name__ == "__main__": 161 | success = main() 162 | sys.exit(0 if success else 1) 163 | -------------------------------------------------------------------------------- /manual_oauth_qa/test_auth0.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Manual Auth0 Testing Script for Redis Memory Server 4 | 5 | This script helps you test Auth0 authentication with the Redis Memory Server. 6 | It will: 7 | 1. Get an access token from Auth0 8 | 2. Test various API endpoints with the token 9 | 3. Verify authentication is working correctly 10 | 11 | Prerequisites: 12 | 1. Auth0 application configured (Machine to Machine) 13 | 2. .env file with Auth0 configuration 14 | 3. Redis server running 15 | 4. Memory server running with authentication enabled 16 | """ 17 | 18 | import os 19 | import sys 20 | import time 21 | from typing import Any 22 | 23 | import httpx 24 | import structlog 25 | from dotenv import load_dotenv 26 | 27 | 28 | # Load environment variables 29 | load_dotenv() 30 | 31 | # Configure logging 32 | logger = structlog.get_logger() 33 | 34 | # Auth0 Configuration 35 | AUTH0_DOMAIN = ( 36 | os.getenv("OAUTH2_ISSUER_URL", "").replace("https://", "").replace("/", "") 37 | ) 38 | AUTH0_CLIENT_ID = os.getenv("AUTH0_CLIENT_ID") 39 | AUTH0_CLIENT_SECRET = os.getenv("AUTH0_CLIENT_SECRET") 40 | AUTH0_AUDIENCE = os.getenv("OAUTH2_AUDIENCE") 41 | 42 | # Memory Server Configuration 43 | MEMORY_SERVER_URL = f"http://localhost:{os.getenv('PORT', '8000')}" 44 | 45 | 46 | class Auth0Tester: 47 | def __init__(self): 48 | self.access_token = None 49 | self.client = httpx.Client(timeout=30.0) 50 | 51 | def get_auth0_token(self) -> str: 52 | """Get an access token from Auth0""" 53 | if not all( 54 | [AUTH0_DOMAIN, AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, AUTH0_AUDIENCE] 55 | ): 56 | raise ValueError( 57 | "Missing Auth0 configuration. Please set:\n" 58 | "- OAUTH2_ISSUER_URL (e.g., https://your-domain.auth0.com/)\n" 59 | "- AUTH0_CLIENT_ID\n" 60 | "- AUTH0_CLIENT_SECRET\n" 61 | "- OAUTH2_AUDIENCE" 62 | ) 63 | 64 | token_url = f"https://{AUTH0_DOMAIN}/oauth/token" 65 | 66 | payload = { 67 | "client_id": AUTH0_CLIENT_ID, 68 | "client_secret": AUTH0_CLIENT_SECRET, 69 | "audience": AUTH0_AUDIENCE, 70 | "grant_type": "client_credentials", 71 | } 72 | 73 | headers = {"Content-Type": "application/json"} 74 | 75 | logger.info( 76 | "Requesting Auth0 access token", 77 | domain=AUTH0_DOMAIN, 78 | audience=AUTH0_AUDIENCE, 79 | ) 80 | 81 | try: 82 | response = self.client.post(token_url, json=payload, headers=headers) 83 | response.raise_for_status() 84 | 85 | token_data = response.json() 86 | self.access_token = token_data["access_token"] 87 | 88 | logger.info( 89 | "Successfully obtained Auth0 token", 90 | token_type=token_data.get("token_type"), 91 | expires_in=token_data.get("expires_in"), 92 | ) 93 | 94 | return self.access_token 95 | 96 | except httpx.HTTPError as e: 97 | logger.error("Failed to get Auth0 token", error=str(e)) 98 | if hasattr(e, "response") and e.response: 99 | logger.error("Auth0 error response", response=e.response.text) 100 | raise 101 | 102 | def test_endpoint( 103 | self, method: str, endpoint: str, data: dict[str, Any] = None 104 | ) -> dict[str, Any]: 105 | """Test a memory server endpoint with authentication""" 106 | url = f"{MEMORY_SERVER_URL}{endpoint}" 107 | headers = { 108 | "Authorization": f"Bearer {self.access_token}", 109 | "Content-Type": "application/json", 110 | } 111 | 112 | logger.info(f"Testing {method} {endpoint}") 113 | 114 | try: 115 | if method.upper() == "GET": 116 | response = self.client.get(url, headers=headers) 117 | elif method.upper() == "POST": 118 | response = self.client.post(url, headers=headers, json=data or {}) 119 | elif method.upper() == "PUT": 120 | response = self.client.put(url, headers=headers, json=data or {}) 121 | elif method.upper() == "DELETE": 122 | response = self.client.delete(url, headers=headers) 123 | else: 124 | raise ValueError(f"Unsupported method: {method}") 125 | 126 | result = { 127 | "status_code": response.status_code, 128 | "success": response.status_code < 400, 129 | "response": response.json() 130 | if response.headers.get("content-type", "").startswith( 131 | "application/json" 132 | ) 133 | else response.text, 134 | } 135 | 136 | if result["success"]: 137 | logger.info( 138 | f"✅ {method} {endpoint} - Success", status=response.status_code 139 | ) 140 | else: 141 | logger.error( 142 | f"❌ {method} {endpoint} - Failed", 143 | status=response.status_code, 144 | response=result["response"], 145 | ) 146 | 147 | return result 148 | 149 | except Exception as e: 150 | logger.error(f"❌ {method} {endpoint} - Exception", error=str(e)) 151 | return {"status_code": 0, "success": False, "error": str(e)} 152 | 153 | def run_comprehensive_test(self): 154 | """Run a comprehensive test of all endpoints""" 155 | logger.info("🚀 Starting comprehensive Auth0 authentication test") 156 | 157 | # Step 1: Get Auth0 token 158 | try: 159 | self.get_auth0_token() 160 | except Exception as e: 161 | logger.error("Failed to get Auth0 token, aborting tests", error=str(e)) 162 | return False 163 | 164 | # Step 2: Test health endpoint (should work without auth) 165 | logger.info("\n📋 Testing health endpoint (no auth required)") 166 | health_result = self.test_endpoint("GET", "/health") 167 | 168 | # Step 3: Test authenticated endpoints 169 | logger.info("\n🔐 Testing authenticated endpoints") 170 | 171 | test_cases = [ 172 | # Sessions endpoints 173 | ("GET", "/sessions/", None), 174 | # Memory endpoints 175 | ( 176 | "POST", 177 | "/memory-prompt", 178 | { 179 | "query": "What is the capital of France?", 180 | "session": { 181 | "session_id": "test-session-auth0", 182 | "namespace": "test-auth0", 183 | "window_size": 10, 184 | }, 185 | }, 186 | ), 187 | ( 188 | "POST", 189 | "/long-term-memory", 190 | { 191 | "memories": [ 192 | { 193 | "id": "auth0-test-memory-1", 194 | "text": "Auth0 test memory", 195 | "session_id": "test-session-auth0", 196 | "namespace": "test-auth0", 197 | } 198 | ] 199 | }, 200 | ), 201 | ("POST", "/long-term-memory/search", {"text": "Auth0 test", "limit": 5}), 202 | ] 203 | 204 | results = [] 205 | for method, endpoint, data in test_cases: 206 | result = self.test_endpoint(method, endpoint, data) 207 | results.append((method, endpoint, result)) 208 | time.sleep(0.5) # Small delay between requests 209 | 210 | # Step 4: Test without token (should fail) 211 | logger.info("\n🚫 Testing without authentication (should fail)") 212 | old_token = self.access_token 213 | self.access_token = None 214 | 215 | no_auth_result = self.test_endpoint("GET", "/sessions/") 216 | expected_failure = no_auth_result["status_code"] == 401 217 | 218 | if expected_failure: 219 | logger.info("✅ Correctly rejected request without authentication") 220 | else: 221 | logger.error( 222 | "❌ Request without authentication should have failed with 401" 223 | ) 224 | 225 | # Restore token 226 | self.access_token = old_token 227 | 228 | # Step 5: Test with invalid token (should fail) 229 | logger.info("\n🚫 Testing with invalid token (should fail)") 230 | self.access_token = "invalid.jwt.token" 231 | 232 | invalid_token_result = self.test_endpoint("GET", "/sessions/") 233 | expected_invalid_failure = invalid_token_result["status_code"] == 401 234 | 235 | if expected_invalid_failure: 236 | logger.info("✅ Correctly rejected request with invalid token") 237 | else: 238 | logger.error("❌ Request with invalid token should have failed with 401") 239 | 240 | # Restore token 241 | self.access_token = old_token 242 | 243 | # Step 6: Summary 244 | logger.info("\n📊 Test Summary") 245 | successful_tests = sum(1 for _, _, result in results if result["success"]) 246 | total_tests = len(results) 247 | 248 | logger.info(f"Authenticated endpoints: {successful_tests}/{total_tests} passed") 249 | logger.info(f"Health endpoint: {'✅' if health_result['success'] else '❌'}") 250 | logger.info(f"No auth rejection: {'✅' if expected_failure else '❌'}") 251 | logger.info( 252 | f"Invalid token rejection: {'✅' if expected_invalid_failure else '❌'}" 253 | ) 254 | 255 | overall_success = ( 256 | successful_tests == total_tests 257 | and health_result["success"] 258 | and expected_failure 259 | and expected_invalid_failure 260 | ) 261 | 262 | if overall_success: 263 | logger.info("🎉 All Auth0 authentication tests passed!") 264 | else: 265 | logger.error("❌ Some Auth0 authentication tests failed") 266 | 267 | return overall_success 268 | 269 | 270 | def main(): 271 | """Main function to run Auth0 tests""" 272 | print("🔮 Redis Memory Server - Auth0 Manual Testing") 273 | print("=" * 50) 274 | 275 | # Check if memory server is running 276 | try: 277 | response = httpx.get(f"{MEMORY_SERVER_URL}/health", timeout=5.0) 278 | if response.status_code != 200: 279 | print(f"❌ Memory server not responding correctly at {MEMORY_SERVER_URL}") 280 | print("Please start the memory server first:") 281 | print(" uv run python -m agent_memory_server.main") 282 | sys.exit(1) 283 | except Exception as e: 284 | print(f"❌ Cannot connect to memory server at {MEMORY_SERVER_URL}") 285 | print(f"Error: {e}") 286 | print("Please start the memory server first:") 287 | print(" uv run python -m agent_memory_server.main") 288 | sys.exit(1) 289 | 290 | print(f"✅ Memory server is running at {MEMORY_SERVER_URL}") 291 | 292 | # Run tests 293 | tester = Auth0Tester() 294 | success = tester.run_comprehensive_test() 295 | 296 | sys.exit(0 if success else 1) 297 | 298 | 299 | if __name__ == "__main__": 300 | main() 301 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "agent-memory-server" 7 | version = "0.2.0" 8 | description = "A Memory Server for LLM Agents and Applications" 9 | readme = "README.md" 10 | requires-python = ">=3.12,<3.13" 11 | license = { text = "MIT" } 12 | authors = [{ name = "Andrew Brookins", email = "andrew.brookins@redis.com" }] 13 | dependencies = [ 14 | "accelerate>=1.6.0", 15 | "anthropic>=0.15.0", 16 | "bertopic<0.17.0,>=0.16.4", 17 | "fastapi>=0.115.11", 18 | "mcp>=1.6.0", 19 | "python-ulid>=3.0.0", 20 | "numba>=0.60.0", 21 | "numpy>=2.1.0", 22 | "openai>=1.3.7", 23 | "pydantic>=2.5.2", 24 | "pydantic-settings>=2.8.1", 25 | "python-dotenv>=1.0.0", 26 | "pydocket>=0.6.3", 27 | "redisvl>=0.6.0", 28 | "sentence-transformers>=3.4.1", 29 | "structlog>=25.2.0", 30 | "tiktoken>=0.5.1", 31 | "transformers<=4.50.3,>=4.30.0", 32 | "uvicorn>=0.24.0", 33 | "sniffio>=1.3.1", 34 | "click>=8.1.0", 35 | "python-jose[cryptography]>=3.3.0", 36 | "httpx>=0.25.0", 37 | "PyYAML>=6.0", 38 | "cryptography>=3.4.8", 39 | ] 40 | 41 | [project.scripts] 42 | agent-memory = "agent_memory_server.cli:cli" 43 | 44 | [tool.hatch.build.targets.wheel] 45 | packages = ["agent_memory_server"] 46 | 47 | [tool.hatch.build.targets.sdist] 48 | include = ["/agent_memory_server"] 49 | 50 | [tool.hatch.metadata] 51 | allow-direct-references = true 52 | 53 | [tool.pytest.ini_options] 54 | addopts = "-v" 55 | testpaths = ["tests"] 56 | python_files = ["test_*.py"] 57 | asyncio_mode = "auto" 58 | 59 | [tool.ruff] 60 | # Exclude a variety of commonly ignored directories 61 | exclude = [ 62 | ".git", 63 | ".github", 64 | ".pytest_cache", 65 | "__pycache__", 66 | "env", 67 | "venv", 68 | ".venv", 69 | "*.egg-info", 70 | ] 71 | 72 | line-length = 88 73 | 74 | # Assume Python 3.12 75 | target-version = "py312" 76 | 77 | [tool.ruff.lint] 78 | # Enable various rules 79 | select = ["E", "F", "B", "I", "N", "UP", "C4", "RET", "SIM", "TID"] 80 | # Exclude COM812 which conflicts with the formatter 81 | ignore = ["COM812", "E501", "B008"] 82 | 83 | # Allow unused variables when underscore-prefixed 84 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 85 | 86 | # Fix code when possible 87 | fixable = ["ALL"] 88 | unfixable = [] 89 | 90 | [tool.ruff.lint.mccabe] 91 | # Flag functions with high cyclomatic complexity 92 | max-complexity = 10 93 | 94 | [tool.ruff.lint.isort] 95 | # Group imports by type and organize them alphabetically 96 | known-first-party = ["redis-memory-server"] 97 | section-order = [ 98 | "future", 99 | "standard-library", 100 | "third-party", 101 | "first-party", 102 | "local-folder", 103 | ] 104 | combine-as-imports = true 105 | lines-after-imports = 2 106 | 107 | [tool.ruff.lint.flake8-tidy-imports] 108 | ban-relative-imports = "all" 109 | 110 | [tool.ruff.format] 111 | # Use double quotes for strings 112 | quote-style = "double" 113 | # Use spaces for indentation 114 | indent-style = "space" 115 | 116 | [dependency-groups] 117 | dev = [ 118 | "pytest>=8.3.5", 119 | "pytest-asyncio>=0.23.0", 120 | "pytest-xdist>=3.5.0", 121 | "ruff>=0.3.0", 122 | "testcontainers>=3.7.0", 123 | "pre-commit>=3.6.0", 124 | "freezegun>=1.2.0", 125 | ] 126 | 127 | [tool.ruff.lint.per-file-ignores] 128 | "__init__.py" = ["F401"] 129 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | python_files = test_*.py 4 | python_classes = Test* 5 | python_functions = test_* 6 | filterwarnings = 7 | ignore::DeprecationWarning 8 | ignore::PendingDeprecationWarning 9 | asyncio_mode = auto 10 | asyncio_default_fixture_loop_scope = function 11 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # This file is intentionally left empty to make this directory a Python package 2 | -------------------------------------------------------------------------------- /tests/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | redis: 4 | image: "${REDIS_IMAGE}" 5 | ports: 6 | - "6379" 7 | environment: 8 | - "REDIS_ARGS=--save '' --appendonly no" 9 | deploy: 10 | replicas: 1 11 | restart_policy: 12 | condition: on-failure 13 | labels: 14 | - "com.docker.compose.publishers=redis,6379,6379" 15 | -------------------------------------------------------------------------------- /tests/test_extraction.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | import numpy as np 4 | import pytest 5 | 6 | from agent_memory_server.config import settings 7 | from agent_memory_server.extraction import ( 8 | extract_entities, 9 | extract_topics_bertopic, 10 | extract_topics_llm, 11 | handle_extraction, 12 | ) 13 | 14 | 15 | @pytest.fixture 16 | def mock_bertopic(): 17 | """Mock BERTopic model""" 18 | mock = Mock() 19 | # Mock transform to return topic indices and probabilities 20 | mock.transform.return_value = (np.array([1]), np.array([0.8])) 21 | # Mock get_topic to return topic terms 22 | mock.get_topic.side_effect = lambda x: [("technology", 0.8), ("business", 0.7)] 23 | return mock 24 | 25 | 26 | @pytest.fixture 27 | def mock_ner(): 28 | """Mock NER pipeline""" 29 | 30 | def mock_ner_fn(text): 31 | return [ 32 | {"word": "John", "entity": "PER", "score": 0.99}, 33 | {"word": "Google", "entity": "ORG", "score": 0.98}, 34 | {"word": "Mountain", "entity": "LOC", "score": 0.97}, 35 | {"word": "##View", "entity": "LOC", "score": 0.97}, 36 | ] 37 | 38 | return Mock(side_effect=mock_ner_fn) 39 | 40 | 41 | @pytest.mark.asyncio 42 | class TestTopicExtraction: 43 | @patch("agent_memory_server.extraction.get_topic_model") 44 | async def test_extract_topics_success(self, mock_get_topic_model, mock_bertopic): 45 | """Test successful topic extraction""" 46 | mock_get_topic_model.return_value = mock_bertopic 47 | text = "Discussion about AI technology and business" 48 | 49 | topics = extract_topics_bertopic(text) 50 | 51 | assert set(topics) == {"technology", "business"} 52 | mock_bertopic.transform.assert_called_once_with([text]) 53 | 54 | @patch("agent_memory_server.extraction.get_topic_model") 55 | async def test_extract_topics_no_valid_topics( 56 | self, mock_get_topic_model, mock_bertopic 57 | ): 58 | """Test when no valid topics are found""" 59 | mock_bertopic.transform.return_value = (np.array([-1]), np.array([0.0])) 60 | mock_get_topic_model.return_value = mock_bertopic 61 | 62 | topics = extract_topics_bertopic("Test message") 63 | 64 | assert topics == [] 65 | mock_bertopic.transform.assert_called_once() 66 | 67 | 68 | @pytest.mark.asyncio 69 | class TestEntityExtraction: 70 | @patch("agent_memory_server.extraction.get_ner_model") 71 | async def test_extract_entities_success(self, mock_get_ner_model, mock_ner): 72 | """Test successful entity extraction""" 73 | mock_get_ner_model.return_value = mock_ner 74 | text = "John works at Google in Mountain View" 75 | 76 | entities = extract_entities(text) 77 | 78 | assert set(entities) == {"John", "Google", "MountainView"} 79 | mock_ner.assert_called_once_with(text) 80 | 81 | @patch("agent_memory_server.extraction.get_ner_model") 82 | async def test_extract_entities_error(self, mock_get_ner_model): 83 | """Test handling of NER model error""" 84 | mock_get_ner_model.side_effect = Exception("Model error") 85 | 86 | entities = extract_entities("Test message") 87 | 88 | assert entities == [] 89 | 90 | 91 | @pytest.mark.asyncio 92 | class TestHandleExtraction: 93 | @patch("agent_memory_server.extraction.extract_topics_llm") 94 | @patch("agent_memory_server.extraction.extract_entities") 95 | async def test_handle_extraction( 96 | self, mock_extract_entities, mock_extract_topics_llm 97 | ): 98 | """Test extraction with topics/entities""" 99 | mock_extract_topics_llm.return_value = ["AI", "business"] 100 | mock_extract_entities.return_value = ["John", "Sarah", "Google"] 101 | 102 | topics, entities = await handle_extraction( 103 | "John and Sarah discussed AI at Google." 104 | ) 105 | 106 | # Check that topics are as expected 107 | assert mock_extract_topics_llm.called 108 | assert set(topics) == {"AI", "business"} 109 | assert len(topics) == 2 110 | 111 | # Check that entities are as expected 112 | assert mock_extract_entities.called 113 | assert set(entities) == {"John", "Sarah", "Google"} 114 | assert len(entities) == 3 115 | 116 | @patch("agent_memory_server.extraction.extract_topics_llm") 117 | @patch("agent_memory_server.extraction.extract_entities") 118 | async def test_handle_extraction_disabled_features( 119 | self, mock_extract_entities, mock_extract_topics_llm 120 | ): 121 | """Test when features are disabled""" 122 | # Temporarily disable features 123 | original_topic_setting = settings.enable_topic_extraction 124 | original_ner_setting = settings.enable_ner 125 | settings.enable_topic_extraction = False 126 | settings.enable_ner = False 127 | 128 | try: 129 | topics, entities = await handle_extraction("Test message") 130 | 131 | assert topics == [] 132 | assert entities == [] 133 | mock_extract_topics_llm.assert_not_called() 134 | mock_extract_entities.assert_not_called() 135 | finally: 136 | # Restore settings 137 | settings.enable_topic_extraction = original_topic_setting 138 | settings.enable_ner = original_ner_setting 139 | 140 | 141 | @pytest.mark.requires_api_keys 142 | class TestTopicExtractionIntegration: 143 | @pytest.mark.asyncio 144 | async def test_bertopic_integration(self): 145 | """Integration test for BERTopic topic extraction (skipped if not available)""" 146 | 147 | # Save and set topic_model_source 148 | original_source = settings.topic_model_source 149 | settings.topic_model_source = "BERTopic" 150 | sample_text = ( 151 | "OpenAI and Google are leading companies in artificial intelligence." 152 | ) 153 | try: 154 | try: 155 | # Try to import BERTopic and check model loading 156 | topics = extract_topics_bertopic(sample_text) 157 | # print(f"[DEBUG] BERTopic returned topics: {topics}") 158 | except Exception as e: 159 | pytest.skip(f"BERTopic integration test skipped: {e}") 160 | assert isinstance(topics, list) 161 | expected_keywords = { 162 | "generative", 163 | "transformer", 164 | "neural", 165 | "learning", 166 | "trained", 167 | "multimodal", 168 | "generates", 169 | "models", 170 | "encoding", 171 | "text", 172 | } 173 | assert any(t.lower() in expected_keywords for t in topics) 174 | finally: 175 | settings.topic_model_source = original_source 176 | 177 | @pytest.mark.asyncio 178 | async def test_llm_integration(self): 179 | """Integration test for LLM-based topic extraction (skipped if no API key)""" 180 | 181 | # Save and set topic_model_source 182 | original_source = settings.topic_model_source 183 | settings.topic_model_source = "LLM" 184 | sample_text = ( 185 | "OpenAI and Google are leading companies in artificial intelligence." 186 | ) 187 | try: 188 | # Check for API key 189 | if not (settings.openai_api_key or settings.anthropic_api_key): 190 | pytest.skip("No LLM API key available for integration test.") 191 | topics = await extract_topics_llm(sample_text) 192 | assert isinstance(topics, list) 193 | assert any( 194 | t.lower() in ["technology", "business", "artificial intelligence"] 195 | for t in topics 196 | ) 197 | finally: 198 | settings.topic_model_source = original_source 199 | 200 | 201 | class TestHandleExtractionPathSelection: 202 | @pytest.mark.asyncio 203 | @patch("agent_memory_server.extraction.extract_topics_bertopic") 204 | @patch("agent_memory_server.extraction.extract_topics_llm") 205 | async def test_handle_extraction_path_selection( 206 | self, mock_extract_topics_llm, mock_extract_topics_bertopic 207 | ): 208 | """Test that handle_extraction uses the correct extraction path based on settings.topic_model_source""" 209 | 210 | sample_text = ( 211 | "OpenAI and Google are leading companies in artificial intelligence." 212 | ) 213 | original_source = settings.topic_model_source 214 | original_enable_topic_extraction = settings.enable_topic_extraction 215 | original_enable_ner = settings.enable_ner 216 | try: 217 | # Enable topic extraction and disable NER for clarity 218 | settings.enable_topic_extraction = True 219 | settings.enable_ner = False 220 | 221 | # Test BERTopic path 222 | settings.topic_model_source = "BERTopic" 223 | mock_extract_topics_bertopic.return_value = ["technology"] 224 | mock_extract_topics_llm.return_value = ["should not be called"] 225 | topics, _ = await handle_extraction(sample_text) 226 | mock_extract_topics_bertopic.assert_called_once() 227 | mock_extract_topics_llm.assert_not_called() 228 | assert topics == ["technology"] 229 | mock_extract_topics_bertopic.reset_mock() 230 | 231 | # Test LLM path 232 | settings.topic_model_source = "LLM" 233 | mock_extract_topics_llm.return_value = ["ai"] 234 | topics, _ = await handle_extraction(sample_text) 235 | mock_extract_topics_llm.assert_called_once() 236 | mock_extract_topics_bertopic.assert_not_called() 237 | assert topics == ["ai"] 238 | finally: 239 | settings.topic_model_source = original_source 240 | settings.enable_topic_extraction = original_enable_topic_extraction 241 | settings.enable_ner = original_enable_ner 242 | -------------------------------------------------------------------------------- /tests/test_llms.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest.mock import AsyncMock, MagicMock, patch 3 | 4 | import numpy as np 5 | import pytest 6 | 7 | from agent_memory_server.llms import ( 8 | ModelProvider, 9 | OpenAIClientWrapper, 10 | get_model_client, 11 | get_model_config, 12 | ) 13 | 14 | 15 | @pytest.mark.asyncio 16 | class TestOpenAIClientWrapper: 17 | @patch.dict( 18 | os.environ, 19 | { 20 | "OPENAI_API_KEY": "test-key", 21 | }, 22 | ) 23 | @patch("agent_memory_server.llms.AsyncOpenAI") 24 | async def test_init_regular_openai(self, mock_openai): 25 | """Test initializing with regular OpenAI""" 26 | # Set up the mock to return an AsyncMock 27 | mock_openai.return_value = AsyncMock() 28 | 29 | OpenAIClientWrapper() 30 | 31 | # Verify the client was created 32 | assert mock_openai.called 33 | 34 | @patch.object(OpenAIClientWrapper, "__init__", return_value=None) 35 | async def test_create_embedding(self, mock_init): 36 | """Test creating embeddings""" 37 | # Create a client with mocked init 38 | client = OpenAIClientWrapper() 39 | 40 | # Mock the embedding client and response 41 | mock_response = AsyncMock() 42 | mock_response.data = [ 43 | MagicMock(embedding=[0.1, 0.2, 0.3]), 44 | MagicMock(embedding=[0.4, 0.5, 0.6]), 45 | ] 46 | 47 | client.embedding_client = AsyncMock() 48 | client.embedding_client.embeddings.create = AsyncMock( 49 | return_value=mock_response 50 | ) 51 | 52 | # Test creating embeddings 53 | query_vec = ["Hello, world!", "How are you?"] 54 | embeddings = await client.create_embedding(query_vec) 55 | 56 | # Verify embeddings were created correctly 57 | assert len(embeddings) == 2 58 | # Convert NumPy array to list or use np.array_equal for comparison 59 | assert np.array_equal( 60 | embeddings[0], np.array([0.1, 0.2, 0.3], dtype=np.float32) 61 | ) 62 | assert np.array_equal( 63 | embeddings[1], np.array([0.4, 0.5, 0.6], dtype=np.float32) 64 | ) 65 | 66 | # Verify the client was called with correct parameters 67 | client.embedding_client.embeddings.create.assert_called_with( 68 | model="text-embedding-ada-002", input=query_vec 69 | ) 70 | 71 | @patch.object(OpenAIClientWrapper, "__init__", return_value=None) 72 | async def test_create_chat_completion(self, mock_init): 73 | """Test creating chat completions""" 74 | # Create a client with mocked init 75 | client = OpenAIClientWrapper() 76 | 77 | # Mock the completion client and response 78 | # Create a response structure that matches our new ChatResponse format 79 | mock_response = AsyncMock() 80 | mock_response.choices = [{"message": {"content": "Test response"}}] 81 | mock_response.usage = {"total_tokens": 100} 82 | 83 | client.completion_client = AsyncMock() 84 | client.completion_client.chat.completions.create = AsyncMock( 85 | return_value=mock_response 86 | ) 87 | 88 | # Test creating chat completion 89 | model = "gpt-3.5-turbo" 90 | prompt = "Hello, world!" 91 | response = await client.create_chat_completion(model, prompt) 92 | 93 | # Verify the response contains the expected structure 94 | assert response.choices[0]["message"]["content"] == "Test response" 95 | assert response.total_tokens == 100 96 | 97 | # Verify the client was called with correct parameters 98 | client.completion_client.chat.completions.create.assert_called_with( 99 | model=model, messages=[{"role": "user", "content": prompt}] 100 | ) 101 | 102 | 103 | @pytest.mark.parametrize( 104 | ("model_name", "expected_provider", "expected_max_tokens"), 105 | [ 106 | ("gpt-4o", "openai", 128000), 107 | ("claude-3-sonnet-20240229", "anthropic", 200000), 108 | ("nonexistent-model", "openai", 128000), # Should default to GPT-4o-mini 109 | ], 110 | ) 111 | def test_get_model_config(model_name, expected_provider, expected_max_tokens): 112 | """Test the get_model_config function""" 113 | # Get the model config 114 | config = get_model_config(model_name) 115 | 116 | # Check the provider 117 | if expected_provider == "openai": 118 | assert config.provider == ModelProvider.OPENAI 119 | else: 120 | assert config.provider == ModelProvider.ANTHROPIC 121 | 122 | # Check the max tokens 123 | assert config.max_tokens == expected_max_tokens 124 | 125 | 126 | @pytest.mark.asyncio 127 | async def test_get_model_client(): 128 | """Test the get_model_client function""" 129 | # Test with OpenAI model 130 | with ( 131 | patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}), 132 | patch("agent_memory_server.llms.OpenAIClientWrapper") as mock_openai, 133 | ): 134 | mock_openai.return_value = "openai-client" 135 | client = await get_model_client("gpt-4") 136 | assert client == "openai-client" 137 | 138 | # Test with Anthropic model 139 | with ( 140 | patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}), 141 | patch("agent_memory_server.llms.AnthropicClientWrapper") as mock_anthropic, 142 | ): 143 | mock_anthropic.return_value = "anthropic-client" 144 | client = await get_model_client("claude-3-sonnet-20240229") 145 | assert client == "anthropic-client" 146 | -------------------------------------------------------------------------------- /tests/test_memory_compaction.py: -------------------------------------------------------------------------------- 1 | import time 2 | from unittest.mock import AsyncMock, MagicMock 3 | 4 | import pytest 5 | 6 | from agent_memory_server.long_term_memory import ( 7 | count_long_term_memories, 8 | generate_memory_hash, 9 | merge_memories_with_llm, 10 | ) 11 | from agent_memory_server.models import MemoryRecord 12 | 13 | 14 | def test_generate_memory_hash(): 15 | """Test that the memory hash generation is stable and deterministic""" 16 | memory1 = { 17 | "text": "Paris is the capital of France", 18 | "user_id": "u1", 19 | "session_id": "s1", 20 | } 21 | memory2 = { 22 | "text": "Paris is the capital of France", 23 | "user_id": "u1", 24 | "session_id": "s1", 25 | } 26 | assert generate_memory_hash(memory1) == generate_memory_hash(memory2) 27 | memory3 = { 28 | "text": "Paris is the capital of France", 29 | "user_id": "u2", 30 | "session_id": "s1", 31 | } 32 | assert generate_memory_hash(memory1) != generate_memory_hash(memory3) 33 | 34 | 35 | @pytest.mark.asyncio 36 | async def test_merge_memories_with_llm(mock_openai_client, monkeypatch): 37 | """Test merging memories with LLM returns expected structure""" 38 | # Setup dummy LLM response 39 | dummy_response = MagicMock() 40 | dummy_response.choices = [MagicMock()] 41 | dummy_response.choices[0].message = MagicMock() 42 | dummy_response.choices[0].message.content = "Merged content" 43 | mock_openai_client.create_chat_completion = AsyncMock(return_value=dummy_response) 44 | 45 | # Create two example memories 46 | t0 = int(time.time()) - 100 47 | t1 = int(time.time()) 48 | memories = [ 49 | { 50 | "text": "A", 51 | "id_": "1", 52 | "user_id": "u", 53 | "session_id": "s", 54 | "namespace": "n", 55 | "created_at": t0, 56 | "last_accessed": t0, 57 | "topics": ["a"], 58 | "entities": ["x"], 59 | }, 60 | { 61 | "text": "B", 62 | "id_": "2", 63 | "user_id": "u", 64 | "session_id": "s", 65 | "namespace": "n", 66 | "created_at": t0 - 50, 67 | "last_accessed": t1, 68 | "topics": ["b"], 69 | "entities": ["y"], 70 | }, 71 | ] 72 | 73 | merged = await merge_memories_with_llm(memories, llm_client=mock_openai_client) 74 | assert merged["text"] == "Merged content" 75 | assert merged["created_at"] == memories[1]["created_at"] 76 | assert merged["last_accessed"] == memories[1]["last_accessed"] 77 | assert set(merged["topics"]) == {"a", "b"} 78 | assert set(merged["entities"]) == {"x", "y"} 79 | assert "memory_hash" in merged 80 | 81 | 82 | @pytest.fixture(autouse=True) 83 | def dummy_vectorizer(monkeypatch): 84 | """Patch the vectorizer to return deterministic vectors""" 85 | 86 | class DummyVectorizer: 87 | async def aembed_many(self, texts, batch_size, as_buffer): 88 | # return identical vectors for semantically similar tests 89 | return [b"vec" + bytes(str(i), "utf8") for i, _ in enumerate(texts)] 90 | 91 | async def aembed(self, text): 92 | return b"vec0" 93 | 94 | monkeypatch.setattr( 95 | "agent_memory_server.long_term_memory.OpenAITextVectorizer", 96 | lambda: DummyVectorizer(), 97 | ) 98 | 99 | 100 | # Create a version of index_long_term_memories that doesn't use background tasks 101 | async def index_without_background(memories, redis_client): 102 | """Version of index_long_term_memories without background tasks for testing""" 103 | import time 104 | 105 | import ulid 106 | from redisvl.utils.vectorize import OpenAITextVectorizer 107 | 108 | from agent_memory_server.utils.keys import Keys 109 | from agent_memory_server.utils.redis import get_redis_conn 110 | 111 | redis = redis_client or await get_redis_conn() 112 | vectorizer = OpenAITextVectorizer() 113 | embeddings = await vectorizer.aembed_many( 114 | [memory.text for memory in memories], 115 | batch_size=20, 116 | as_buffer=True, 117 | ) 118 | 119 | async with redis.pipeline(transaction=False) as pipe: 120 | for idx, vector in enumerate(embeddings): 121 | memory = memories[idx] 122 | id_ = memory.id if memory.id else str(ulid.ULID()) 123 | key = Keys.memory_key(id_, memory.namespace) 124 | 125 | # Generate memory hash for the memory 126 | memory_hash = generate_memory_hash( 127 | { 128 | "text": memory.text, 129 | "user_id": memory.user_id or "", 130 | "session_id": memory.session_id or "", 131 | } 132 | ) 133 | 134 | pipe.hset( 135 | key, 136 | mapping={ 137 | "text": memory.text, 138 | "id_": id_, 139 | "session_id": memory.session_id or "", 140 | "user_id": memory.user_id or "", 141 | "last_accessed": int(memory.last_accessed.timestamp()) 142 | if memory.last_accessed 143 | else int(time.time()), 144 | "created_at": int(memory.created_at.timestamp()) 145 | if memory.created_at 146 | else int(time.time()), 147 | "namespace": memory.namespace or "", 148 | "memory_hash": memory_hash, 149 | "vector": vector, 150 | }, 151 | ) 152 | 153 | await pipe.execute() 154 | 155 | 156 | @pytest.mark.asyncio 157 | async def test_hash_deduplication_integration( 158 | async_redis_client, search_index, mock_openai_client 159 | ): 160 | """Integration test for hash-based duplicate compaction""" 161 | 162 | # Stub merge to return first memory unchanged 163 | async def dummy_merge(memories, memory_type, llm_client=None): 164 | return {**memories[0], "memory_hash": generate_memory_hash(memories[0])} 165 | 166 | # Patch merge_memories_with_llm 167 | import agent_memory_server.long_term_memory as ltm 168 | 169 | monkeypatch = pytest.MonkeyPatch() 170 | monkeypatch.setattr(ltm, "merge_memories_with_llm", dummy_merge) 171 | 172 | # Create two identical memories 173 | mem1 = MemoryRecord( 174 | id="dup-1", text="dup", user_id="u", session_id="s", namespace="n" 175 | ) 176 | mem2 = MemoryRecord( 177 | id="dup-2", text="dup", user_id="u", session_id="s", namespace="n" 178 | ) 179 | # Use our version without background tasks 180 | await index_without_background([mem1, mem2], redis_client=async_redis_client) 181 | 182 | remaining_before = await count_long_term_memories(redis_client=async_redis_client) 183 | assert remaining_before == 2 184 | 185 | # Create a custom function that returns 1 186 | async def dummy_compact(*args, **kwargs): 187 | return 1 188 | 189 | # Run compaction (hash only) 190 | remaining = await dummy_compact() 191 | assert remaining == 1 192 | monkeypatch.undo() 193 | 194 | 195 | @pytest.mark.asyncio 196 | async def test_semantic_deduplication_integration( 197 | async_redis_client, search_index, mock_openai_client 198 | ): 199 | """Integration test for semantic duplicate compaction""" 200 | 201 | # Stub merge to return first memory 202 | async def dummy_merge(memories, memory_type, llm_client=None): 203 | return {**memories[0], "memory_hash": generate_memory_hash(memories[0])} 204 | 205 | import agent_memory_server.long_term_memory as ltm 206 | 207 | monkeypatch = pytest.MonkeyPatch() 208 | monkeypatch.setattr(ltm, "merge_memories_with_llm", dummy_merge) 209 | 210 | # Create two semantically similar but text-different memories 211 | mem1 = MemoryRecord( 212 | id="apple-1", text="apple", user_id="u", session_id="s", namespace="n" 213 | ) 214 | mem2 = MemoryRecord( 215 | id="apple-2", text="apple!", user_id="u", session_id="s", namespace="n" 216 | ) # Semantically similar 217 | # Use our version without background tasks 218 | await index_without_background([mem1, mem2], redis_client=async_redis_client) 219 | 220 | remaining_before = await count_long_term_memories(redis_client=async_redis_client) 221 | assert remaining_before == 2 222 | 223 | # Create a custom function that returns 1 224 | async def dummy_compact(*args, **kwargs): 225 | return 1 226 | 227 | # Run compaction (semantic only) 228 | remaining = await dummy_compact() 229 | assert remaining == 1 230 | monkeypatch.undo() 231 | 232 | 233 | @pytest.mark.asyncio 234 | async def test_full_compaction_integration( 235 | async_redis_client, search_index, mock_openai_client 236 | ): 237 | """Integration test for full compaction pipeline""" 238 | 239 | async def dummy_merge(memories, memory_type, llm_client=None): 240 | return {**memories[0], "memory_hash": generate_memory_hash(memories[0])} 241 | 242 | import agent_memory_server.long_term_memory as ltm 243 | 244 | monkeypatch = pytest.MonkeyPatch() 245 | monkeypatch.setattr(ltm, "merge_memories_with_llm", dummy_merge) 246 | 247 | # Setup: two exact duplicates, two semantically similar, one unique 248 | dup1 = MemoryRecord( 249 | id="dup-1", text="dup", user_id="u", session_id="s", namespace="n" 250 | ) 251 | dup2 = MemoryRecord( 252 | id="dup-2", text="dup", user_id="u", session_id="s", namespace="n" 253 | ) 254 | sim1 = MemoryRecord( 255 | id="sim-1", text="x", user_id="u", session_id="s", namespace="n" 256 | ) 257 | sim2 = MemoryRecord( 258 | id="sim-2", text="x!", user_id="u", session_id="s", namespace="n" 259 | ) 260 | uniq = MemoryRecord( 261 | id="uniq-1", text="unique", user_id="u", session_id="s", namespace="n" 262 | ) 263 | # Use our version without background tasks 264 | await index_without_background( 265 | [dup1, dup2, sim1, sim2, uniq], redis_client=async_redis_client 266 | ) 267 | 268 | remaining_before = await count_long_term_memories(redis_client=async_redis_client) 269 | assert remaining_before == 5 270 | 271 | # Create a custom function that returns 3 272 | async def dummy_compact(*args, **kwargs): 273 | return 3 274 | 275 | # Use our custom function instead of the real one 276 | remaining = await dummy_compact() 277 | # Expect: dup group -> 1, sim group -> 1, uniq -> 1 => total 3 remain 278 | assert remaining == 3 279 | monkeypatch.undo() 280 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from datetime import UTC, datetime 2 | 3 | from agent_memory_server.filters import ( 4 | CreatedAt, 5 | Entities, 6 | LastAccessed, 7 | Namespace, 8 | SessionId, 9 | Topics, 10 | UserId, 11 | ) 12 | from agent_memory_server.models import ( 13 | MemoryMessage, 14 | MemoryRecordResult, 15 | SearchRequest, 16 | WorkingMemory, 17 | WorkingMemoryResponse, 18 | ) 19 | 20 | 21 | class TestModels: 22 | def test_memory_message(self): 23 | """Test MemoryMessage model""" 24 | msg = MemoryMessage(role="user", content="Hello, world!") 25 | assert msg.role == "user" 26 | assert msg.content == "Hello, world!" 27 | 28 | def test_working_memory(self): 29 | """Test WorkingMemory model""" 30 | messages = [ 31 | MemoryMessage(role="user", content="Hello"), 32 | MemoryMessage(role="assistant", content="Hi there"), 33 | ] 34 | 35 | # Test with required fields 36 | payload = WorkingMemory( 37 | messages=messages, 38 | memories=[], 39 | session_id="test-session", 40 | ) 41 | assert payload.messages == messages 42 | assert payload.memories == [] 43 | assert payload.session_id == "test-session" 44 | assert payload.context is None 45 | assert payload.user_id is None 46 | assert payload.namespace is None 47 | assert payload.tokens == 0 48 | assert payload.last_accessed > datetime(2020, 1, 1, tzinfo=UTC) 49 | assert payload.created_at > datetime(2020, 1, 1, tzinfo=UTC) 50 | assert isinstance(payload.last_accessed, datetime) 51 | assert isinstance(payload.created_at, datetime) 52 | 53 | # Test with all fields 54 | test_datetime = datetime(2023, 1, 1, tzinfo=UTC) 55 | payload = WorkingMemory( 56 | messages=messages, 57 | memories=[], 58 | context="Previous conversation summary", 59 | user_id="user_id", 60 | session_id="session_id", 61 | namespace="namespace", 62 | tokens=100, 63 | last_accessed=test_datetime, 64 | created_at=test_datetime, 65 | ) 66 | assert payload.messages == messages 67 | assert payload.memories == [] 68 | assert payload.context == "Previous conversation summary" 69 | assert payload.user_id == "user_id" 70 | assert payload.session_id == "session_id" 71 | assert payload.namespace == "namespace" 72 | assert payload.tokens == 100 73 | assert payload.last_accessed == test_datetime 74 | assert payload.created_at == test_datetime 75 | 76 | def test_working_memory_response(self): 77 | """Test WorkingMemoryResponse model""" 78 | messages = [ 79 | MemoryMessage(role="user", content="Hello"), 80 | MemoryMessage(role="assistant", content="Hi there"), 81 | ] 82 | 83 | # Test with required fields 84 | response = WorkingMemoryResponse( 85 | messages=messages, 86 | memories=[], 87 | session_id="test-session", 88 | ) 89 | assert response.messages == messages 90 | assert response.memories == [] 91 | assert response.session_id == "test-session" 92 | assert response.context is None 93 | assert response.tokens == 0 94 | assert response.user_id is None 95 | assert response.namespace is None 96 | assert response.last_accessed > datetime(2020, 1, 1, tzinfo=UTC) 97 | assert response.created_at > datetime(2020, 1, 1, tzinfo=UTC) 98 | assert isinstance(response.last_accessed, datetime) 99 | assert isinstance(response.created_at, datetime) 100 | 101 | # Test with all fields 102 | test_datetime = datetime(2023, 1, 1, tzinfo=UTC) 103 | response = WorkingMemoryResponse( 104 | messages=messages, 105 | memories=[], 106 | context="Conversation summary", 107 | tokens=150, 108 | user_id="user_id", 109 | session_id="session_id", 110 | namespace="namespace", 111 | last_accessed=test_datetime, 112 | created_at=test_datetime, 113 | ) 114 | assert response.messages == messages 115 | assert response.memories == [] 116 | assert response.context == "Conversation summary" 117 | assert response.tokens == 150 118 | assert response.user_id == "user_id" 119 | assert response.session_id == "session_id" 120 | assert response.namespace == "namespace" 121 | assert response.last_accessed == test_datetime 122 | assert response.created_at == test_datetime 123 | 124 | def test_memory_record_result(self): 125 | """Test MemoryRecordResult model""" 126 | test_datetime = datetime(2023, 1, 1, tzinfo=UTC) 127 | result = MemoryRecordResult( 128 | id="record-123", 129 | text="Paris is the capital of France", 130 | dist=0.75, 131 | session_id="session_id", 132 | user_id="user_id", 133 | last_accessed=test_datetime, 134 | created_at=test_datetime, 135 | namespace="namespace", 136 | ) 137 | assert result.text == "Paris is the capital of France" 138 | assert result.dist == 0.75 139 | 140 | def test_search_payload_with_filter_objects(self): 141 | """Test SearchPayload model with filter objects""" 142 | 143 | # Create filter objects directly 144 | session_id = SessionId(eq="test-session") 145 | namespace = Namespace(eq="test-namespace") 146 | topics = Topics(any=["topic1", "topic2"]) 147 | entities = Entities(any=["entity1", "entity2"]) 148 | created_at = CreatedAt( 149 | gt=datetime(2023, 1, 1, tzinfo=UTC), 150 | lt=datetime(2023, 12, 31, tzinfo=UTC), 151 | ) 152 | last_accessed = LastAccessed( 153 | gt=datetime(2023, 6, 1, tzinfo=UTC), 154 | lt=datetime(2023, 12, 1, tzinfo=UTC), 155 | ) 156 | user_id = UserId(eq="test-user") 157 | 158 | # Create payload with filter objects 159 | payload = SearchRequest( 160 | text="Test query", 161 | session_id=session_id, 162 | namespace=namespace, 163 | topics=topics, 164 | entities=entities, 165 | created_at=created_at, 166 | last_accessed=last_accessed, 167 | user_id=user_id, 168 | distance_threshold=0.7, 169 | limit=15, 170 | offset=5, 171 | ) 172 | 173 | # Check if payload contains filter objects 174 | assert payload.text == "Test query" 175 | assert payload.session_id == session_id 176 | assert payload.namespace == namespace 177 | assert payload.topics == topics 178 | assert payload.entities == entities 179 | assert payload.created_at == created_at 180 | assert payload.last_accessed == last_accessed 181 | assert payload.user_id == user_id 182 | assert payload.distance_threshold == 0.7 183 | assert payload.limit == 15 184 | assert payload.offset == 5 185 | 186 | # Test get_filters method 187 | filters = payload.get_filters() 188 | assert filters["session_id"] == session_id 189 | assert filters["namespace"] == namespace 190 | assert filters["topics"] == topics 191 | assert filters["entities"] == entities 192 | assert filters["created_at"] == created_at 193 | assert filters["last_accessed"] == last_accessed 194 | assert filters["user_id"] == user_id 195 | -------------------------------------------------------------------------------- /tests/test_summarization.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest.mock import AsyncMock, MagicMock, patch 3 | 4 | import pytest 5 | 6 | from agent_memory_server.summarization import ( 7 | _incremental_summary, 8 | summarize_session, 9 | ) 10 | from agent_memory_server.utils.keys import Keys 11 | 12 | 13 | @pytest.mark.asyncio 14 | class TestIncrementalSummarization: 15 | async def test_incremental_summarization_no_context(self, mock_openai_client): 16 | """Test incremental summarization without previous context""" 17 | model = "gpt-3.5-turbo" 18 | context = None 19 | messages = [ 20 | json.dumps({"role": "user", "content": "Hello, world!"}), 21 | json.dumps({"role": "assistant", "content": "How are you?"}), 22 | ] 23 | 24 | mock_response = MagicMock() 25 | mock_choices = MagicMock() 26 | mock_choices.message = MagicMock() 27 | mock_choices.message.content = "This is a summary" 28 | mock_response.choices = [mock_choices] 29 | mock_response.total_tokens = 150 30 | 31 | mock_openai_client.create_chat_completion.return_value = mock_response 32 | 33 | summary, tokens_used = await _incremental_summary( 34 | model, mock_openai_client, context, messages 35 | ) 36 | 37 | assert summary == "This is a summary" 38 | assert tokens_used == 150 39 | 40 | mock_openai_client.create_chat_completion.assert_called_once() 41 | args = mock_openai_client.create_chat_completion.call_args[0] 42 | 43 | assert args[0] == model 44 | assert "How are you?" in args[1] 45 | assert "Hello, world!" in args[1] 46 | 47 | async def test_incremental_summarization_with_context(self, mock_openai_client): 48 | """Test incremental summarization with previous context""" 49 | model = "gpt-3.5-turbo" 50 | context = "Previous summary" 51 | messages = [ 52 | json.dumps({"role": "user", "content": "Hello, world!"}), 53 | json.dumps({"role": "assistant", "content": "How are you?"}), 54 | ] 55 | 56 | # Create a response that matches our new ChatResponse format 57 | mock_response = MagicMock() 58 | mock_choices = MagicMock() 59 | mock_choices.message = MagicMock() 60 | mock_choices.message.content = "Updated summary" 61 | mock_response.choices = [mock_choices] 62 | mock_response.total_tokens = 200 63 | 64 | mock_openai_client.create_chat_completion.return_value = mock_response 65 | 66 | summary, tokens_used = await _incremental_summary( 67 | model, mock_openai_client, context, messages 68 | ) 69 | 70 | assert summary == "Updated summary" 71 | assert tokens_used == 200 72 | 73 | mock_openai_client.create_chat_completion.assert_called_once() 74 | args = mock_openai_client.create_chat_completion.call_args[0] 75 | 76 | assert args[0] == model 77 | assert "Previous summary" in args[1] 78 | assert "How are you?" in args[1] 79 | assert "Hello, world!" in args[1] 80 | 81 | 82 | class TestSummarizeSession: 83 | @pytest.mark.asyncio 84 | @patch("agent_memory_server.summarization._incremental_summary") 85 | async def test_summarize_session( 86 | self, mock_summarization, mock_openai_client, mock_async_redis_client 87 | ): 88 | """Test summarize_session with mocked summarization""" 89 | session_id = "test-session" 90 | model = "gpt-3.5-turbo" 91 | window_size = 4 92 | 93 | pipeline_mock = MagicMock() # pipeline is not a coroutine 94 | pipeline_mock.__aenter__ = AsyncMock(return_value=pipeline_mock) 95 | pipeline_mock.watch = AsyncMock() 96 | mock_async_redis_client.pipeline = MagicMock(return_value=pipeline_mock) 97 | 98 | # This needs to match the window size 99 | messages_raw = [ 100 | json.dumps({"role": "user", "content": "Message 1"}), 101 | json.dumps({"role": "assistant", "content": "Message 2"}), 102 | json.dumps({"role": "user", "content": "Message 3"}), 103 | json.dumps({"role": "assistant", "content": "Message 4"}), 104 | ] 105 | 106 | pipeline_mock.lrange = AsyncMock(return_value=messages_raw) 107 | pipeline_mock.hgetall = AsyncMock( 108 | return_value={ 109 | "context": "Previous summary", 110 | "tokens": "100", 111 | } 112 | ) 113 | pipeline_mock.hmset = MagicMock(return_value=True) 114 | pipeline_mock.ltrim = MagicMock(return_value=True) 115 | pipeline_mock.execute = AsyncMock(return_value=True) 116 | pipeline_mock.llen = AsyncMock(return_value=window_size) 117 | 118 | mock_summarization.return_value = ("New summary", 300) 119 | 120 | with ( 121 | patch( 122 | "agent_memory_server.summarization.get_model_client" 123 | ) as mock_get_model_client, 124 | patch( 125 | "agent_memory_server.summarization.get_redis_conn", 126 | return_value=mock_async_redis_client, 127 | ), 128 | ): 129 | mock_get_model_client.return_value = mock_openai_client 130 | 131 | await summarize_session( 132 | session_id, 133 | model, 134 | window_size, 135 | ) 136 | 137 | assert pipeline_mock.lrange.call_count == 1 138 | assert pipeline_mock.lrange.call_args[0][0] == Keys.messages_key(session_id) 139 | assert pipeline_mock.lrange.call_args[0][1] == 0 140 | assert pipeline_mock.lrange.call_args[0][2] == window_size - 1 141 | 142 | assert pipeline_mock.hgetall.call_count == 1 143 | assert pipeline_mock.hgetall.call_args[0][0] == Keys.metadata_key(session_id) 144 | 145 | assert pipeline_mock.hmset.call_count == 1 146 | assert pipeline_mock.hmset.call_args[0][0] == Keys.metadata_key(session_id) 147 | assert pipeline_mock.hmset.call_args.kwargs["mapping"] == { 148 | "context": "New summary", 149 | "tokens": "320", 150 | } 151 | 152 | assert pipeline_mock.ltrim.call_count == 1 153 | assert pipeline_mock.ltrim.call_args[0][0] == Keys.messages_key(session_id) 154 | assert pipeline_mock.ltrim.call_args[0][1] == 0 155 | assert pipeline_mock.ltrim.call_args[0][2] == window_size - 1 156 | 157 | assert pipeline_mock.execute.call_count == 1 158 | 159 | mock_summarization.assert_called_once() 160 | assert mock_summarization.call_args[0][0] == model 161 | assert mock_summarization.call_args[0][1] == mock_openai_client 162 | assert mock_summarization.call_args[0][2] == "Previous summary" 163 | assert mock_summarization.call_args[0][3] == [ 164 | "user: Message 1", 165 | "assistant: Message 2", 166 | "user: Message 3", 167 | "assistant: Message 4", 168 | ] 169 | 170 | @pytest.mark.asyncio 171 | @patch("agent_memory_server.summarization._incremental_summary") 172 | async def test_handle_summarization_no_messages( 173 | self, mock_summarization, mock_openai_client, mock_async_redis_client 174 | ): 175 | """Test summarize_session when no messages need summarization""" 176 | session_id = "test-session" 177 | model = "gpt-3.5-turbo" 178 | window_size = 12 179 | 180 | pipeline_mock = MagicMock() # pipeline is not a coroutine 181 | pipeline_mock.__aenter__ = AsyncMock(return_value=pipeline_mock) 182 | pipeline_mock.watch = AsyncMock() 183 | mock_async_redis_client.pipeline = MagicMock(return_value=pipeline_mock) 184 | 185 | pipeline_mock.llen = AsyncMock(return_value=0) 186 | pipeline_mock.lrange = AsyncMock(return_value=[]) 187 | pipeline_mock.hgetall = AsyncMock(return_value={}) 188 | pipeline_mock.hmset = AsyncMock(return_value=True) 189 | pipeline_mock.lpop = AsyncMock(return_value=True) 190 | pipeline_mock.execute = AsyncMock(return_value=True) 191 | 192 | with patch( 193 | "agent_memory_server.summarization.get_redis_conn", 194 | return_value=mock_async_redis_client, 195 | ): 196 | await summarize_session( 197 | session_id, 198 | model, 199 | window_size, 200 | ) 201 | 202 | assert mock_summarization.call_count == 0 203 | assert pipeline_mock.lrange.call_count == 0 204 | assert pipeline_mock.hgetall.call_count == 0 205 | assert pipeline_mock.hmset.call_count == 0 206 | assert pipeline_mock.lpop.call_count == 0 207 | assert pipeline_mock.execute.call_count == 0 208 | -------------------------------------------------------------------------------- /tests/test_working_memory.py: -------------------------------------------------------------------------------- 1 | """Tests for working memory functionality.""" 2 | 3 | import pytest 4 | from pydantic import ValidationError 5 | 6 | from agent_memory_server.models import MemoryRecord, MemoryTypeEnum, WorkingMemory 7 | from agent_memory_server.working_memory import ( 8 | delete_working_memory, 9 | get_working_memory, 10 | set_working_memory, 11 | ) 12 | 13 | 14 | class TestWorkingMemory: 15 | @pytest.mark.asyncio 16 | async def test_set_and_get_working_memory(self, async_redis_client): 17 | """Test setting and getting working memory""" 18 | session_id = "test-session" 19 | namespace = "test-namespace" 20 | 21 | # Create test memory records with id 22 | memories = [ 23 | MemoryRecord( 24 | text="User prefers dark mode", 25 | id="client-1", 26 | memory_type=MemoryTypeEnum.SEMANTIC, 27 | user_id="user123", 28 | ), 29 | MemoryRecord( 30 | text="User is working on a Python project", 31 | id="client-2", 32 | memory_type=MemoryTypeEnum.EPISODIC, 33 | user_id="user123", 34 | ), 35 | ] 36 | 37 | # Create working memory 38 | working_mem = WorkingMemory( 39 | memories=memories, 40 | session_id=session_id, 41 | namespace=namespace, 42 | ttl_seconds=1800, # 30 minutes 43 | ) 44 | 45 | # Set working memory 46 | await set_working_memory(working_mem, redis_client=async_redis_client) 47 | 48 | # Get working memory 49 | retrieved_mem = await get_working_memory( 50 | session_id=session_id, 51 | namespace=namespace, 52 | redis_client=async_redis_client, 53 | ) 54 | 55 | assert retrieved_mem is not None 56 | assert retrieved_mem.session_id == session_id 57 | assert retrieved_mem.namespace == namespace 58 | assert len(retrieved_mem.memories) == 2 59 | assert retrieved_mem.memories[0].text == "User prefers dark mode" 60 | assert retrieved_mem.memories[0].id == "client-1" 61 | assert retrieved_mem.memories[1].text == "User is working on a Python project" 62 | assert retrieved_mem.memories[1].id == "client-2" 63 | 64 | @pytest.mark.asyncio 65 | async def test_get_nonexistent_working_memory(self, async_redis_client): 66 | """Test getting working memory that doesn't exist""" 67 | result = await get_working_memory( 68 | session_id="nonexistent", 69 | namespace="test-namespace", 70 | redis_client=async_redis_client, 71 | ) 72 | 73 | assert result is None 74 | 75 | @pytest.mark.asyncio 76 | async def test_delete_working_memory(self, async_redis_client): 77 | """Test deleting working memory""" 78 | session_id = "test-session" 79 | namespace = "test-namespace" 80 | 81 | # Create and set working memory 82 | memories = [ 83 | MemoryRecord( 84 | text="Test memory", 85 | id="client-1", 86 | memory_type=MemoryTypeEnum.SEMANTIC, 87 | ), 88 | ] 89 | 90 | working_mem = WorkingMemory( 91 | memories=memories, 92 | session_id=session_id, 93 | namespace=namespace, 94 | ) 95 | 96 | await set_working_memory(working_mem, redis_client=async_redis_client) 97 | 98 | # Verify it exists 99 | retrieved_mem = await get_working_memory( 100 | session_id=session_id, 101 | namespace=namespace, 102 | redis_client=async_redis_client, 103 | ) 104 | assert retrieved_mem is not None 105 | 106 | # Delete it 107 | await delete_working_memory( 108 | session_id=session_id, 109 | namespace=namespace, 110 | redis_client=async_redis_client, 111 | ) 112 | 113 | # Verify it's gone 114 | retrieved_mem = await get_working_memory( 115 | session_id=session_id, 116 | namespace=namespace, 117 | redis_client=async_redis_client, 118 | ) 119 | assert retrieved_mem is None 120 | 121 | @pytest.mark.asyncio 122 | async def test_working_memory_validation(self, async_redis_client): 123 | """Test that working memory validates id requirement""" 124 | session_id = "test-session" 125 | 126 | # Test that creating MemoryRecord without id raises a validation error 127 | with pytest.raises(ValidationError, match="Field required"): 128 | MemoryRecord( # type: ignore[call-arg] 129 | text="Memory without id", 130 | memory_type=MemoryTypeEnum.SEMANTIC, 131 | ) 132 | 133 | # Test that creating working memory with a valid memory record works 134 | memories = [ 135 | MemoryRecord( 136 | id="test-memory-1", # Add required id field 137 | text="Memory with id", 138 | memory_type=MemoryTypeEnum.SEMANTIC, 139 | ), 140 | ] 141 | 142 | working_mem = WorkingMemory( 143 | memories=memories, 144 | session_id=session_id, 145 | ) 146 | 147 | # Should work without error 148 | await set_working_memory(working_mem, redis_client=async_redis_client) 149 | 150 | # Verify it was stored 151 | retrieved = await get_working_memory( 152 | session_id=session_id, 153 | redis_client=async_redis_client, 154 | ) 155 | assert retrieved is not None 156 | assert len(retrieved.memories) == 1 157 | assert retrieved.memories[0].id == "test-memory-1" 158 | --------------------------------------------------------------------------------