From e1dce23129c24239d09b92a5a5708f53f6ab868a Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Wed, 10 Dec 2025 11:43:25 -0800 Subject: [PATCH 1/4] Fix non-docket background tasks for MCP server --- agent_memory_server/__init__.py | 2 +- agent_memory_server/cli.py | 9 +-- agent_memory_server/dependencies.py | 56 ++++++++++++++++--- tests/test_dependencies.py | 86 ++++++++++++++++++++--------- 4 files changed, 110 insertions(+), 43 deletions(-) diff --git a/agent_memory_server/__init__.py b/agent_memory_server/__init__.py index e45e0b0..5ee75fa 100644 --- a/agent_memory_server/__init__.py +++ b/agent_memory_server/__init__.py @@ -1,3 +1,3 @@ """Redis Agent Memory Server - A memory system for conversational AI.""" -__version__ = "0.12.5" +__version__ = "0.12.6" diff --git a/agent_memory_server/cli.py b/agent_memory_server/cli.py index 32a27f3..b80ec7b 100644 --- a/agent_memory_server/cli.py +++ b/agent_memory_server/cli.py @@ -319,13 +319,8 @@ def mcp(port: int, mode: str, no_worker: bool): async def setup_and_run(): # Redis setup is handled by the MCP app before it starts - # Set use_docket based on mode and --no-worker flag - if mode == "stdio": - # Don't run a task worker in stdio mode by default - settings.use_docket = False - elif no_worker: - # Use --no-worker flag for SSE mode - settings.use_docket = False + # Set use_docket based on --no-worker flag + settings.use_docket = no_worker # Run the MCP server if mode == "sse": diff --git a/agent_memory_server/dependencies.py b/agent_memory_server/dependencies.py index 255a40a..31e5786 100644 --- a/agent_memory_server/dependencies.py +++ b/agent_memory_server/dependencies.py @@ -1,8 +1,10 @@ +import asyncio import concurrent.futures from collections.abc import Callable from typing import Any from fastapi import BackgroundTasks +from starlette.concurrency import run_in_threadpool from agent_memory_server.config import settings from agent_memory_server.logging import get_logger @@ -12,11 +14,26 @@ class HybridBackgroundTasks(BackgroundTasks): - """A BackgroundTasks implementation that can use either Docket or FastAPI background tasks.""" + """A BackgroundTasks implementation that can use either Docket or asyncio tasks. + + When use_docket=True, tasks are scheduled through Docket's Redis-based queue + for processing by a separate worker process. + + When use_docket=False, tasks are scheduled using asyncio.create_task() to run + in the current event loop. This works in both FastAPI and MCP contexts, unlike + the parent class's approach which relies on Starlette's response lifecycle + (which doesn't exist in MCP's stdio/SSE modes). + """ def add_task(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> None: - """Run tasks either directly, through Docket, or through FastAPI background tasks""" - logger.info("Adding task to background tasks...") + """Schedule a background task for execution. + + Args: + func: The function to run (can be sync or async) + *args: Positional arguments to pass to the function + **kwargs: Keyword arguments to pass to the function + """ + logger.info(f"Adding background task: {func.__name__}") if settings.use_docket: logger.info("Scheduling task through Docket") @@ -28,8 +45,6 @@ def add_task(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> None: # This runs in a thread to avoid event loop conflicts def run_in_thread(): """Run the async Docket operations in a separate thread""" - import asyncio - async def schedule_task(): async with Docket( name=settings.docket_name, @@ -48,9 +63,34 @@ async def schedule_task(): # When using Docket, we don't add anything to FastAPI background tasks else: - logger.info("Using FastAPI background tasks") - # Use FastAPI's background tasks directly - super().add_task(func, *args, **kwargs) + logger.info("Scheduling task with asyncio.create_task") + # Use asyncio.create_task to schedule the task in the event loop. + # This works universally in both FastAPI and MCP contexts. + # + # Note: We don't use super().add_task() because Starlette's BackgroundTasks + # relies on being attached to a response object and run after the response + # is sent. In MCP mode (stdio/SSE), there's no Starlette response lifecycle, + # so tasks added via super().add_task() would never execute. + asyncio.create_task(self._run_task(func, *args, **kwargs)) + + async def _run_task( + self, func: Callable[..., Any], *args: Any, **kwargs: Any + ) -> None: + """Execute a background task, handling both sync and async functions. + + Args: + func: The function to run (can be sync or async) + *args: Positional arguments to pass to the function + **kwargs: Keyword arguments to pass to the function + """ + try: + if asyncio.iscoroutinefunction(func): + await func(*args, **kwargs) + else: + # Run sync functions in a thread pool to avoid blocking the event loop + await run_in_threadpool(func, *args, **kwargs) + except Exception as e: + logger.error(f"Background task {func.__name__} failed: {e}", exc_info=True) # Backwards compatibility alias diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index 0879eb1..96b3be7 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -25,24 +25,49 @@ def teardown_method(self): """Restore original use_docket setting.""" settings.use_docket = self.original_use_docket - def test_add_task_with_fastapi_background_tasks(self): - """Test that tasks are added to FastAPI background tasks when use_docket=False.""" + @pytest.mark.asyncio + async def test_add_task_executes_async_function(self): + """Test that async tasks are executed when use_docket=False.""" + settings.use_docket = False + + bg_tasks = HybridBackgroundTasks() + result = {"called": False, "args": None} + + async def test_func(arg1, arg2=None): + result["called"] = True + result["args"] = (arg1, arg2) + + # Add task + bg_tasks.add_task(test_func, "hello", arg2="world") + + # Give the task time to execute + await asyncio.sleep(0.1) + + # Verify task was executed with correct arguments + assert result["called"] is True + assert result["args"] == ("hello", "world") + + @pytest.mark.asyncio + async def test_add_task_executes_sync_function(self): + """Test that sync tasks are executed in a thread pool when use_docket=False.""" settings.use_docket = False bg_tasks = HybridBackgroundTasks() + result = {"called": False, "args": None} def test_func(arg1, arg2=None): - return f"test_func called with {arg1}, {arg2}" + result["called"] = True + result["args"] = (arg1, arg2) # Add task bg_tasks.add_task(test_func, "hello", arg2="world") - # Verify task was added to FastAPI background tasks - assert len(bg_tasks.tasks) == 1 - task = bg_tasks.tasks[0] - assert task.func is test_func - assert task.args == ("hello",) - assert task.kwargs == {"arg2": "world"} + # Give the task time to execute + await asyncio.sleep(0.1) + + # Verify task was executed with correct arguments + assert result["called"] is True + assert result["args"] == ("hello", "world") @pytest.mark.asyncio async def test_add_task_with_docket(self): @@ -87,20 +112,23 @@ async def test_func(arg1, arg2=None): ) mock_docket_instance.add.assert_called_once_with(test_func) - def test_add_task_logs_correctly_for_fastapi(self): - """Test that FastAPI background tasks work without errors.""" + @pytest.mark.asyncio + async def test_add_task_handles_errors_gracefully(self): + """Test that task errors are logged but don't crash the system.""" settings.use_docket = False bg_tasks = HybridBackgroundTasks() - def test_func(): - pass + async def failing_task(): + raise ValueError("Test error") - # Should not raise any errors - bg_tasks.add_task(test_func) + # Should not raise any errors when adding the task + bg_tasks.add_task(failing_task) + + # Give the task time to execute (and fail) + await asyncio.sleep(0.1) - # Verify task was added to FastAPI background tasks - assert len(bg_tasks.tasks) == 1 + # If we got here, the error was handled gracefully @pytest.mark.asyncio async def test_add_task_logs_correctly_for_docket(self): @@ -179,18 +207,22 @@ def teardown_method(self): @pytest.mark.asyncio async def test_respects_settings_change(self): """Test that HybridBackgroundTasks respects runtime changes to settings.use_docket.""" - bg_tasks = HybridBackgroundTasks() + result = {"asyncio_called": False, "docket_called": False} - def test_func(): - pass + async def test_func(): + result["asyncio_called"] = True - # Test with use_docket=False + # Test with use_docket=False - should use asyncio.create_task settings.use_docket = False + bg_tasks = HybridBackgroundTasks() bg_tasks.add_task(test_func) - assert len(bg_tasks.tasks) == 1 - # Clear tasks and test with use_docket=True - bg_tasks.tasks.clear() + # Give the task time to execute + await asyncio.sleep(0.1) + + assert result["asyncio_called"] is True + + # Test with use_docket=True - should use Docket settings.use_docket = True with patch("docket.Docket") as mock_docket_class: @@ -201,19 +233,19 @@ def test_func(): def mock_task_callable(*args, **kwargs): async def execution(): + result["docket_called"] = True return "executed" return execution() mock_docket_instance.add.return_value = mock_task_callable - bg_tasks.add_task(test_func) + bg_tasks2 = HybridBackgroundTasks() + bg_tasks2.add_task(test_func) # Give the async task a moment to complete await asyncio.sleep(0.1) - # Should not add to FastAPI tasks when use_docket=True - assert len(bg_tasks.tasks) == 0 # Should have called Docket mock_docket_class.assert_called_once() From 634339f518ce8f4dee34406ef50b35663fca558b Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Wed, 10 Dec 2025 11:43:38 -0800 Subject: [PATCH 2/4] lint --- agent_memory_server/dependencies.py | 1 + 1 file changed, 1 insertion(+) diff --git a/agent_memory_server/dependencies.py b/agent_memory_server/dependencies.py index 31e5786..f85b7b6 100644 --- a/agent_memory_server/dependencies.py +++ b/agent_memory_server/dependencies.py @@ -45,6 +45,7 @@ def add_task(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> None: # This runs in a thread to avoid event loop conflicts def run_in_thread(): """Run the async Docket operations in a separate thread""" + async def schedule_task(): async with Docket( name=settings.docket_name, From 71df149b4e0684576df8a5077b0973d7ea328561 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Wed, 10 Dec 2025 17:55:26 -0800 Subject: [PATCH 3/4] Deprecate --no-worker, add --task-backend --- Dockerfile | 18 ++++--- README.md | 10 ++-- agent_memory_server/cli.py | 58 ++++++++++++++++---- docker-compose-task-workers.yml | 12 ++--- docs/cli.md | 19 +++---- docs/getting-started.md | 14 ++--- docs/quick-start.md | 34 ++++++------ tests/test_cli.py | 94 +++++++++++++++++++++------------ 8 files changed, 162 insertions(+), 97 deletions(-) diff --git a/Dockerfile b/Dockerfile index 844e5f9..177519c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -112,13 +112,14 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ # You may override with DISABLE_AUTH=true in development. ENV DISABLE_AUTH=false -# Default to development mode (no separate worker needed). -# For production, override the command to remove --no-worker and run a separate task-worker container. +# Default to development mode using the API's default backend (Docket). For +# single-process development without a worker, add `--task-backend=asyncio` to +# the api command. # Examples: -# Development: docker run -p 8000:8000 redislabs/agent-memory-server +# Development: docker run -p 8000:8000 redislabs/agent-memory-server agent-memory api --host 0.0.0.0 --port 8000 --task-backend=asyncio # Production API: docker run -p 8000:8000 redislabs/agent-memory-server agent-memory api --host 0.0.0.0 --port 8000 # Production Worker: docker run redislabs/agent-memory-server agent-memory task-worker --concurrency 10 -CMD ["agent-memory", "api", "--host", "0.0.0.0", "--port", "8000", "--no-worker"] +CMD ["agent-memory", "api", "--host", "0.0.0.0", "--port", "8000"] # ============================================ # AWS VARIANT - Includes AWS Bedrock support @@ -142,10 +143,11 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ # You may override with DISABLE_AUTH=true in development. ENV DISABLE_AUTH=false -# Default to development mode (no separate worker needed). -# For production, override the command to remove --no-worker and run a separate task-worker container. +# Default to development mode using the API's default backend (Docket). For +# single-process development without a worker, add `--task-backend=asyncio` to +# the api command. # Examples: -# Development: docker run -p 8000:8000 redislabs/agent-memory-server:aws +# Development: docker run -p 8000:8000 redislabs/agent-memory-server:aws agent-memory api --host 0.0.0.0 --port 8000 --task-backend=asyncio # Production API: docker run -p 8000:8000 redislabs/agent-memory-server:aws agent-memory api --host 0.0.0.0 --port 8000 # Production Worker: docker run redislabs/agent-memory-server:aws agent-memory task-worker --concurrency 10 -CMD ["agent-memory", "api", "--host", "0.0.0.0", "--port", "8000", "--no-worker"] +CMD ["agent-memory", "api", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 5e1b7cb..40add54 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ docker run -p 8000:8000 \ redislabs/agent-memory-server:latest ``` -The default image runs in development mode (`--no-worker`), which is perfect for testing and development. +The default image runs in development mode using the **asyncio** task backend (no separate worker required), which is perfect for testing and development. **Production Deployment**: @@ -74,8 +74,8 @@ uv install --all-extras # Start Redis docker-compose up redis -# Start the server (development mode) -uv run agent-memory api --no-worker +# Start the server (development mode, default asyncio backend) +uv run agent-memory api ``` ### 2. Python SDK @@ -155,8 +155,8 @@ result = await executor.ainvoke({"input": "Remember that I love pizza"}) # Start MCP server (stdio mode - recommended for Claude Desktop) uv run agent-memory mcp -# Or with SSE mode (development mode) -uv run agent-memory mcp --mode sse --port 9000 --no-worker +# Or with SSE mode (development mode, default asyncio backend) +uv run agent-memory mcp --mode sse --port 9000 ``` ### MCP config via uvx (recommended) diff --git a/agent_memory_server/cli.py b/agent_memory_server/cli.py index b80ec7b..18e601a 100644 --- a/agent_memory_server/cli.py +++ b/agent_memory_server/cli.py @@ -257,7 +257,7 @@ async def run_migration(): ) else: click.echo( - "\nMigration completed with errors. " "Run again to retry failed keys." + "\nMigration completed with errors. Run again to retry failed keys." ) asyncio.run(run_migration()) @@ -268,16 +268,41 @@ async def run_migration(): @click.option("--host", default="0.0.0.0", help="Host to run the server on") @click.option("--reload", is_flag=True, help="Enable auto-reload") @click.option( - "--no-worker", is_flag=True, help="Use FastAPI background tasks instead of Docket" + "--no-worker", + is_flag=True, + help=( + "(DEPRECATED) Use --task-backend=asyncio instead. " + "If present, force FastAPI/asyncio background tasks instead of Docket." + ), + deprecated=True, +) +@click.option( + "--task-backend", + default="docket", + type=click.Choice(["asyncio", "docket"]), + help=( + "Background task backend (asyncio, docket). " + "Default is 'docket' to preserve existing behavior using Docket-based " + "workers (requires a running `agent-memory task-worker` for " + "non-blocking background tasks). Use 'asyncio' (or deprecated " + "--no-worker) for single-process development without a worker." + ), ) -def api(port: int, host: str, reload: bool, no_worker: bool): +def api(port: int, host: str, reload: bool, no_worker: bool, task_backend: str): """Run the REST API server.""" from agent_memory_server.main import on_start_logger configure_logging() - # Set use_docket based on the --no-worker flag - if no_worker: + # Determine effective backend. + # - Default is 'docket' to preserve prior behavior (Docket workers). + # - --task-backend=asyncio opts into single-process asyncio background tasks. + # - Deprecated --no-worker flag forces asyncio for backward compatibility. + effective_backend = "asyncio" if no_worker else task_backend + + if effective_backend == "docket": + settings.use_docket = True + else: # "asyncio" settings.use_docket = False on_start_logger(port) @@ -298,9 +323,17 @@ def api(port: int, host: str, reload: bool, no_worker: bool): type=click.Choice(["stdio", "sse"]), ) @click.option( - "--no-worker", is_flag=True, help="Use FastAPI background tasks instead of Docket" + "--task-backend", + default="asyncio", + type=click.Choice(["asyncio", "docket"]), + help=( + "Background task backend (asyncio, docket). " + "Default is 'asyncio' (no separate worker needed). " + "Use 'docket' for production setups with a running task worker." + "(see `agent-memory task-worker`)." + ), ) -def mcp(port: int, mode: str, no_worker: bool): +def mcp(port: int, mode: str, task_backend: str): """Run the MCP server.""" import asyncio @@ -317,10 +350,13 @@ def mcp(port: int, mode: str, no_worker: bool): from agent_memory_server.mcp import mcp_app async def setup_and_run(): - # Redis setup is handled by the MCP app before it starts - - # Set use_docket based on --no-worker flag - settings.use_docket = no_worker + # Configure background task backend for MCP. + # Default is asyncio (no separate worker required). Use 'docket' to + # send tasks to a separate worker process. + if task_backend == "docket": + settings.use_docket = True + else: # "asyncio" + settings.use_docket = False # Run the MCP server if mode == "sse": diff --git a/docker-compose-task-workers.yml b/docker-compose-task-workers.yml index d42fc3b..94227ae 100644 --- a/docker-compose-task-workers.yml +++ b/docker-compose-task-workers.yml @@ -10,8 +10,8 @@ networks: services: # For testing a production-like setup, you can run this API and the - # task-worker container. This API container does NOT use --no-worker, so when - # it starts background work, the task-worker will process those tasks. + # task-worker container. This API container uses --task-backend=docket, so + # when it starts background work, the task-worker will process those tasks. # ============================================================================= # STANDARD (OpenAI/Anthropic only) @@ -44,7 +44,7 @@ services: interval: 30s timeout: 10s retries: 3 - command: ["agent-memory", "api", "--host", "0.0.0.0", "--port", "8000"] + command: ["agent-memory", "api", "--host", "0.0.0.0", "--port", "8000", "--task-backend", "docket"] mcp: profiles: ["standard", ""] @@ -64,7 +64,7 @@ services: - "9050:9000" depends_on: - redis - command: ["agent-memory", "mcp", "--mode", "sse"] + command: ["agent-memory", "mcp", "--mode", "sse", "--task-backend", "docket"] networks: - server-network @@ -126,7 +126,7 @@ services: interval: 30s timeout: 10s retries: 3 - command: ["agent-memory", "api", "--host", "0.0.0.0", "--port", "8000"] + command: ["agent-memory", "api", "--host", "0.0.0.0", "--port", "8000", "--task-backend", "docket"] mcp-aws: profiles: ["aws"] @@ -151,7 +151,7 @@ services: - "9050:9000" depends_on: - redis - command: ["agent-memory", "mcp", "--mode", "sse"] + command: ["agent-memory", "mcp", "--mode", "sse", "--task-backend", "docket"] networks: - server-network diff --git a/docs/cli.md b/docs/cli.md index 0d2218e..86b34d5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -27,15 +27,16 @@ agent-memory api [OPTIONS] - `--port INTEGER`: Port to run the server on. (Default: value from `settings.port`, usually 8000) - `--host TEXT`: Host to run the server on. (Default: "0.0.0.0") - `--reload`: Enable auto-reload for development. -- `--no-worker`: Use FastAPI background tasks instead of Docket workers. Ideal for development and testing. +- `--task-backend [asyncio|docket]`: Background task backend. `docket` (default) uses Docket-based background workers (requires a running `agent-memory task-worker` for non-blocking tasks). `asyncio` runs tasks inline in the API process and does **not** require a separate worker. +- `--no-worker` (**deprecated**): Backwards-compatible alias for `--task-backend=asyncio`. Maintained for older scripts; prefer `--task-backend`. **Examples:** ```bash -# Development mode (no separate worker needed) -agent-memory api --port 8080 --reload --no-worker +# Development mode (no separate worker needed, asyncio backend) +agent-memory api --port 8080 --reload --task-backend asyncio -# Production mode (requires separate worker process) +# Production mode (default Docket backend; requires separate worker process) agent-memory api --port 8080 ``` @@ -51,22 +52,22 @@ agent-memory mcp [OPTIONS] - `--port INTEGER`: Port to run the MCP server on. (Default: value from `settings.mcp_port`, usually 9000) - `--mode [stdio|sse]`: Run the MCP server in stdio or SSE mode. (Default: stdio) -- `--no-worker`: Use FastAPI background tasks instead of Docket workers. Ideal for development and testing. +- `--task-backend [asyncio|docket]`: Background task backend. `asyncio` (default) runs tasks inline in the MCP process with no separate worker. `docket` sends tasks to a Docket queue, which requires running `agent-memory task-worker`. **Examples:** ```bash -# Stdio mode (recommended for Claude Desktop) - automatically uses --no-worker +# Stdio mode (recommended for Claude Desktop) - default asyncio backend agent-memory mcp # SSE mode for development (no separate worker needed) -agent-memory mcp --mode sse --port 9001 --no-worker +agent-memory mcp --mode sse --port 9001 # SSE mode for production (requires separate worker process) -agent-memory mcp --mode sse --port 9001 +agent-memory mcp --mode sse --port 9001 --task-backend docket ``` -**Note:** Stdio mode automatically disables Docket workers as they're not needed when Claude Desktop manages the process lifecycle. +**Note:** Stdio mode is designed for tools like Claude Desktop and, by default, uses the asyncio backend (no worker). Use `--task-backend docket` if you want MCP to enqueue background work into a shared Docket worker. ### `schedule-task` diff --git a/docs/getting-started.md b/docs/getting-started.md index 80d43a4..0ad7be1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -28,10 +28,10 @@ But you can also run these components via the CLI commands. Here's how you run the REST API server: ```bash -# Development mode (no separate worker needed) -uv run agent-memory api --no-worker +# Development mode (no separate worker needed, asyncio backend) +uv run agent-memory api --task-backend asyncio -# Production mode (requires separate worker process) +# Production mode (default Docket backend; requires separate worker process) uv run agent-memory api ``` @@ -42,10 +42,10 @@ Or the MCP server: uv run agent-memory mcp # SSE mode for development -uv run agent-memory mcp --mode sse --no-worker - -# SSE mode for production uv run agent-memory mcp --mode sse + +# SSE mode for production (use Docket backend) +uv run agent-memory mcp --mode sse --task-backend docket ``` ### Using uvx in MCP clients @@ -80,7 +80,7 @@ Notes: uv run agent-memory task-worker ``` -**For development**, use the `--no-worker` flag to run tasks inline without needing a separate worker process. +**For development**, the default `--task-backend=asyncio` on the `mcp` command runs tasks inline without needing a separate worker process. For the `api` command, use `--task-backend=asyncio` explicitly when you want single-process behavior. **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`. diff --git a/docs/quick-start.md b/docs/quick-start.md index cfab63b..fbb6911 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -77,8 +77,8 @@ EOF Start the REST API server: ```bash -# Start the API server in development mode (runs on port 8000) -uv run agent-memory api --no-worker +# Start the API server in development mode (runs on port 8000, asyncio backend) +uv run agent-memory api ``` Your server is now running at `http://localhost:8000`! @@ -307,7 +307,7 @@ For web-based MCP clients, you can use SSE mode, but this requires manually star ```bash # Only needed for SSE mode (development) -uv run agent-memory mcp --mode sse --port 9000 --no-worker +uv run agent-memory mcp --mode sse --port 9000 ``` **Recommendation**: Use stdio mode with Claude Desktop as it's much simpler to set up. @@ -355,11 +355,11 @@ Now that you have the basics working, explore these advanced features: ## Production Deployment -The examples above use `--no-worker` for development convenience. For production environments, you should use Docket workers for better reliability, scalability, and performance. +The examples above use asyncio task backends for simple, single-process development. For production environments, the `api` command defaults to the **Docket** backend (no flag needed), while the `mcp` command still defaults to **asyncio** for single-process MCP usage. Use `--task-backend=docket` with `mcp` when you want MCP to enqueue background work for workers. ### Why Use Workers in Production? -**Development mode (`--no-worker`):** +**Development mode (asyncio backend):** - ✅ Quick setup, no extra processes needed - ✅ Perfect for testing and development - ❌ Tasks run inline, blocking API responses @@ -375,10 +375,10 @@ The examples above use `--no-worker` for development convenience. For production ### Production Setup Steps -1. **Start the API server (without --no-worker):** +1. **Start the API server (default Docket backend):** ```bash -# Production API server +# Production API server (uses Docket by default; requires task-worker) uv run agent-memory api --port 8000 ``` @@ -396,8 +396,8 @@ uv run agent-memory task-worker --concurrency 5 & 3. **Start MCP server (if using SSE mode):** ```bash -# Production MCP server (stdio mode doesn't need changes) -uv run agent-memory mcp --mode sse --port 9000 +# Production MCP server (uses Docket backend) +uv run agent-memory mcp --mode sse --port 9000 --task-backend docket ``` 4. **Enable authentication:** @@ -463,7 +463,7 @@ services: - "9000:9000" environment: - REDIS_URL=redis://redis:6379 - command: uv run agent-memory mcp --mode sse --port 9000 + command: uv run agent-memory mcp --mode sse --port 9000 --task-backend docket depends_on: - redis @@ -489,14 +489,14 @@ redis-cli -h localhost -p 6379 | Use Case | Mode | Command | |----------|------|---------| -| **Quick testing** | Development | `uv run agent-memory api --no-worker` | -| **Local development** | Development | `uv run agent-memory api --no-worker` | +| **Quick testing** | Development | `uv run agent-memory api --task-backend asyncio` | +| **Local development** | Development | `uv run agent-memory api --reload --task-backend asyncio` | | **Production API** | Production | `uv run agent-memory api` + workers | | **High-scale deployment** | Production | `uv run agent-memory api` + multiple workers | -| **Claude Desktop MCP** | Either | `uv run agent-memory mcp` (stdio mode) | -| **Web MCP clients** | Either | `uv run agent-memory mcp --mode sse [--no-worker]` | +| **Claude Desktop MCP** | Either | `uv run agent-memory mcp` (stdio mode, asyncio backend) | +| **Web MCP clients** | Either | `uv run agent-memory mcp --mode sse [--task-backend docket]` | -**Recommendation**: Start with `--no-worker` for development, then graduate to worker-based deployment for production. +**Recommendation**: Start with the asyncio backend (`--task-backend asyncio`) for simple development runs, then rely on the default Docket backend for the API in production, and enable `--task-backend=docket` for MCP when you want shared workers. ## Common Issues @@ -513,8 +513,8 @@ redis-cli -h localhost -p 6379 - If still failing, try: `uv add redisvl>=0.6.0` **"Background tasks not processing"** -- If using `--no-worker`: Tasks run inline, check API server logs -- If using workers: Make sure task worker is running: `uv run agent-memory task-worker` +- If using the asyncio backend: Tasks run inline, check API/MCP server logs +- If using workers (`--task-backend docket` or API default in production): Make sure task worker is running: `uv run agent-memory task-worker` - Check worker logs for errors and ensure Redis is accessible ## Get Help diff --git a/tests/test_cli.py b/tests/test_cli.py index 797e922..452e445 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -114,17 +114,21 @@ def teardown_method(self): @patch("agent_memory_server.cli.uvicorn.run") @patch("agent_memory_server.main.on_start_logger") def test_api_command_defaults(self, mock_on_start_logger, mock_uvicorn_run): - """Test api command with default parameters.""" + """Test api command with default parameters uses Docket backend. + + By default, we preserve existing behavior by enabling Docket-based + background tasks (settings.use_docket should be True). + """ from agent_memory_server.config import settings # Set initial state - settings.use_docket = True + settings.use_docket = False runner = CliRunner() result = runner.invoke(api) assert result.exit_code == 0 - # Should not change use_docket when --no-worker is not specified + # Default should enable Docket-based background tasks assert settings.use_docket is True mock_on_start_logger.assert_called_once() @@ -158,7 +162,11 @@ def test_api_command_with_options(self, mock_on_start_logger, mock_uvicorn_run): def test_api_command_with_no_worker_flag( self, mock_on_start_logger, mock_uvicorn_run ): - """Test api command with --no-worker flag.""" + """Test api command with deprecated --no-worker flag. + + The flag should continue to force asyncio background tasks and not + require a Docket worker. + """ from agent_memory_server.config import settings # Set initial state @@ -181,10 +189,10 @@ def test_api_command_with_no_worker_flag( @patch("agent_memory_server.cli.uvicorn.run") @patch("agent_memory_server.main.on_start_logger") - def test_api_command_with_combined_options( + def test_api_command_with_task_backend_asyncio( self, mock_on_start_logger, mock_uvicorn_run ): - """Test api command with --no-worker and other options.""" + """Test api command with --task-backend=asyncio and other options.""" from agent_memory_server.config import settings # Set initial state @@ -192,11 +200,20 @@ def test_api_command_with_combined_options( runner = CliRunner() result = runner.invoke( - api, ["--port", "9999", "--host", "127.0.0.1", "--reload", "--no-worker"] + api, + [ + "--port", + "9999", + "--host", + "127.0.0.1", + "--reload", + "--task-backend", + "asyncio", + ], ) assert result.exit_code == 0 - # Should set use_docket to False + # Should opt into asyncio when task-backend=asyncio is specified assert settings.use_docket is False mock_on_start_logger.assert_called_once_with(9999) @@ -207,14 +224,17 @@ def test_api_command_with_combined_options( reload=True, ) - def test_api_command_help_includes_no_worker(self): - """Test that API command help includes --no-worker option.""" + def test_api_command_help_includes_task_backend_and_no_worker(self): + """Test that API help mentions deprecated --no-worker and new task-backend.""" runner = CliRunner() result = runner.invoke(api, ["--help"]) assert result.exit_code == 0 assert "--no-worker" in result.output - assert "Use FastAPI background tasks instead of Docket" in result.output + assert "DEPRECATED" in result.output + assert "--task-backend" in result.output + assert "asyncio" in result.output + assert "docket" in result.output class TestMcpCommand: @@ -284,10 +304,14 @@ def test_mcp_command_stdio_logging_config( @patch("agent_memory_server.cli.configure_mcp_logging") @patch("agent_memory_server.mcp.mcp_app") - def test_mcp_command_stdio_mode_defaults_to_no_worker( + def test_mcp_command_stdio_mode_uses_asyncio_by_default( self, mock_mcp_app, mock_configure_mcp_logging ): - """Test that stdio mode defaults to use_docket=False.""" + """Test that stdio mode uses asyncio backend by default. + + Default behavior should not require a separate Docket worker, so + settings.use_docket should be False. + """ from agent_memory_server.config import settings # Set initial state @@ -299,16 +323,16 @@ def test_mcp_command_stdio_mode_defaults_to_no_worker( result = runner.invoke(mcp, ["--mode", "stdio"]) assert result.exit_code == 0 - # stdio mode should set use_docket to False by default + # stdio mode should switch to asyncio backend by default assert settings.use_docket is False mock_mcp_app.run_stdio_async.assert_called_once() @patch("agent_memory_server.cli.configure_logging") @patch("agent_memory_server.mcp.mcp_app") - def test_mcp_command_sse_mode_preserves_docket( + def test_mcp_command_sse_mode_uses_asyncio_by_default( self, mock_mcp_app, mock_configure_logging ): - """Test that SSE mode preserves use_docket=True by default.""" + """Test that SSE mode uses asyncio backend by default.""" from agent_memory_server.config import settings # Set initial state @@ -320,50 +344,50 @@ def test_mcp_command_sse_mode_preserves_docket( result = runner.invoke(mcp, ["--mode", "sse"]) assert result.exit_code == 0 - # SSE mode should keep use_docket as True by default - assert settings.use_docket is True + # SSE mode should also use asyncio backend by default + assert settings.use_docket is False mock_mcp_app.run_sse_async.assert_called_once() @patch("agent_memory_server.cli.configure_logging") @patch("agent_memory_server.mcp.mcp_app") - def test_mcp_command_sse_mode_with_no_worker_flag( + def test_mcp_command_sse_mode_with_task_backend_docket( self, mock_mcp_app, mock_configure_logging ): - """Test that SSE mode with --no-worker flag sets use_docket=False.""" + """Test that SSE mode with --task-backend=docket sets use_docket=True.""" from agent_memory_server.config import settings # Set initial state - settings.use_docket = True + settings.use_docket = False mock_mcp_app.run_sse_async = AsyncMock() runner = CliRunner() - result = runner.invoke(mcp, ["--mode", "sse", "--no-worker"]) + result = runner.invoke(mcp, ["--mode", "sse", "--task-backend", "docket"]) assert result.exit_code == 0 - # SSE mode with --no-worker should set use_docket to False - assert settings.use_docket is False + # SSE mode with task-backend=docket should enable Docket + assert settings.use_docket is True mock_mcp_app.run_sse_async.assert_called_once() @patch("agent_memory_server.cli.configure_mcp_logging") @patch("agent_memory_server.mcp.mcp_app") - def test_mcp_command_stdio_mode_with_no_worker_flag( + def test_mcp_command_stdio_mode_with_task_backend_docket( self, mock_mcp_app, mock_configure_mcp_logging ): - """Test that stdio mode with --no-worker flag still sets use_docket=False.""" + """Test that stdio mode with --task-backend=docket sets use_docket=True.""" from agent_memory_server.config import settings # Set initial state - settings.use_docket = True + settings.use_docket = False mock_mcp_app.run_stdio_async = AsyncMock() runner = CliRunner() - result = runner.invoke(mcp, ["--mode", "stdio", "--no-worker"]) + result = runner.invoke(mcp, ["--mode", "stdio", "--task-backend", "docket"]) assert result.exit_code == 0 - # stdio mode should set use_docket to False regardless of --no-worker flag - assert settings.use_docket is False + # stdio mode with task-backend=docket should enable Docket + assert settings.use_docket is True mock_mcp_app.run_stdio_async.assert_called_once() @patch("agent_memory_server.cli.configure_logging") @@ -385,14 +409,16 @@ def test_mcp_command_port_setting_update( assert settings.mcp_port == 7777 assert settings.mcp_port != original_port - def test_mcp_command_help_includes_no_worker(self): - """Test that MCP command help includes --no-worker option.""" + def test_mcp_command_help_includes_task_backend(self): + """Test that MCP command help includes --task-backend option.""" runner = CliRunner() result = runner.invoke(mcp, ["--help"]) assert result.exit_code == 0 - assert "--no-worker" in result.output - assert "Use FastAPI background tasks instead of Docket" in result.output + assert "--task-backend" in result.output + assert "asyncio" in result.output + assert "docket" in result.output + assert "--no-worker" not in result.output def test_mcp_command_mode_choices(self): """Test that MCP command only accepts valid mode choices.""" From a6fae66afd9e60745170951b69bb9940d35b8e7e Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Wed, 10 Dec 2025 22:17:04 -0800 Subject: [PATCH 4/4] Address review feedback --- README.md | 8 ++++++-- agent_memory_server/cli.py | 2 +- docs/quick-start.md | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 40add54..e36ccf7 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,14 @@ docker-compose up docker run -p 8000:8000 \ -e REDIS_URL=redis://your-redis:6379 \ -e OPENAI_API_KEY=your-key \ - redislabs/agent-memory-server:latest + redislabs/agent-memory-server:latest \ + agent-memory api --host 0.0.0.0 --port 8000 --task-backend=asyncio ``` -The default image runs in development mode using the **asyncio** task backend (no separate worker required), which is perfect for testing and development. +By default, the image runs the API with the **Docket** task backend, which +expects a separate `agent-memory task-worker` process for non-blocking +background tasks. The example above shows how to override this to use the +asyncio backend for a single-container development setup. **Production Deployment**: diff --git a/agent_memory_server/cli.py b/agent_memory_server/cli.py index 18e601a..b63e1d4 100644 --- a/agent_memory_server/cli.py +++ b/agent_memory_server/cli.py @@ -329,7 +329,7 @@ def api(port: int, host: str, reload: bool, no_worker: bool, task_backend: str): help=( "Background task backend (asyncio, docket). " "Default is 'asyncio' (no separate worker needed). " - "Use 'docket' for production setups with a running task worker." + "Use 'docket' for production setups with a running task worker " "(see `agent-memory task-worker`)." ), ) diff --git a/docs/quick-start.md b/docs/quick-start.md index fbb6911..915c6fb 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -78,7 +78,7 @@ Start the REST API server: ```bash # Start the API server in development mode (runs on port 8000, asyncio backend) -uv run agent-memory api +uv run agent-memory api --task-backend=asyncio ``` Your server is now running at `http://localhost:8000`!