diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ca848f6..b11ae4e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,5 +1,5 @@ For additional context about technologies to be used, project structure, shell commands, and other important information, read the current plan: -[specs/004-eval-kit/plan.md](../specs/004-eval-kit/plan.md) +[specs/005-async-fastapi/plan.md](../specs/005-async-fastapi/plan.md) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16057fc..5cbcde2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: - name: Install run: | python -m pip install --upgrade pip - pip install -e "./sdk-python[dev]" + pip install -e "./sdk-python[dev,async]" - name: Validate OpenAPI spec (Principle I) run: omp-validate-spec - name: Run conformance suite (overall ≥85%) diff --git a/.specify/feature.json b/.specify/feature.json index 8f07d33..df49289 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1 +1 @@ -{"feature_directory":"specs/004-eval-kit"} +{"feature_directory":"specs/005-async-fastapi"} diff --git a/CHANGELOG.md b/CHANGELOG.md index 684a6aa..0c1eedb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,58 @@ All notable changes to this project will be documented in this file. +## [0.4.0] — Unreleased (M3.2 PR-A — `AsyncMemory`) + +### Added + +- **`openmem.AsyncMemory`** — async/await-native facade mirroring + `openmem.Memory`. Constructor performs zero blocking I/O; pools and + HTTP clients are built lazily on first verb call (data-model + AM-INV-3 / C-LIFE-1). Routes to native or threadwrap backends per + provider: + - `postgres` → `AsyncPostgresAdapter` on top of `asyncpg` + pgvector + (native cancel; SQL placeholders translated `%s` → `$1` from the + shared `_postgres_sql` module so sync/async share one source of + truth). + - `passthrough` → `AsyncPassthroughAdapter` on top of + `httpx.AsyncClient` (native cancel; identical timeout / retry + headers to sync). + - `mem0` / `supermemory` / `letta` → `AsyncThreadwrapAdapter` over a + per-instance `ThreadPoolExecutor` (best-effort cancel; orphan + completion logged at DEBUG). +- **Three-tier cancellation contract** (`contracts/async-memory.md` §3): + - C-CAN-1: native awaiter receives `CancelledError` ≤ 50 ms. + - C-CAN-2: native pool/socket released ≤ 500 ms post-cancel. + - C-CAN-3: Postgres server-side query aborted ≤ 1 s. + - C-CAN-4: threadwrap awaiter cancels immediately; worker thread + finishes in the background. + - C-CAN-5: subsequent verbs on the same `AsyncMemory` succeed after + cancellation (no pool corruption). +- **`[async]` install extra** (`asyncpg>=0.29`, `httpx>=0.27`). + `from openmem import AsyncMemory` raises a clear `ImportError` + containing `pip install 'openmem[async]'` when the extra is missing + (FR-026 / C-EXT-1..3). Importing bare `openmem` triggers no async + dependency resolution. +- **Cross-loop guard** — `AsyncMemory` captures the running loop id on + the first verb call and raises `RuntimeError` *before* any backend + call if a later verb is awaited on a different loop (C-LOOP-1). +- **Sync `Memory` signature regression test** + (`tests/async/test_memory_signatures.py`) commits a JSON snapshot of + every public verb's signature; any drift fails the gate (SC-008 / + FR-011). + +### Changed + +- `sdk-python/pyproject.toml`: bumped `version` to `0.4.0`; declared + the new `[async]` extra; `[server]` extra now depends on + `openmem[async]`. + +### Preserved + +- The sync `openmem.Memory` class is **byte-identical** — no public + signature, default value, or behavior changed. The full sync test + suite still passes (365 passed / 21 skipped at branch-off baseline). + ## [0.2.1] — Unreleased (M2.1 — Live-API bridges) ### Added diff --git a/README.md b/README.md index 3470d3d..60749c8 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ print(ctx.text) ## Tooling - `openmem-eval` — manual benchmark harness comparing recall, MRR, and latency across configured providers. **Never runs in CI.** Default invocation is a dry-run that makes zero network calls. See [specs/004-eval-kit/quickstart.md](specs/004-eval-kit/quickstart.md) for usage and [docs/eval/](docs/eval/README.md) for a sample report and trace from a real postgres run. +- `openmem.AsyncMemory` — async/await-native facade mirroring `openmem.Memory`. Postgres + passthrough adapters use native async clients (asyncpg, httpx); mem0/supermemory/letta are wrapped with a per-instance thread pool. Cancellation propagates within 50 ms on the native tier. Install with `pip install 'openmem[async]'` and see [specs/005-async-fastapi/quickstart.md](specs/005-async-fastapi/quickstart.md) §1–§4. ## License diff --git a/sdk-python/README.md b/sdk-python/README.md index a058b52..326e527 100644 --- a/sdk-python/README.md +++ b/sdk-python/README.md @@ -9,8 +9,38 @@ Protocol](../spec/OMP-0.1.md) v0.1. pip install -e . # core pip install -e ".[dev]" # core + tests pip install -e ".[openai]" # core + OpenAIEmbedder +pip install -e ".[async]" # core + AsyncMemory (asyncpg) ``` +## Async usage + +`openmem.AsyncMemory` is the async/await-native mirror of +`openmem.Memory`. Method names, parameters, and error semantics match +the sync class — the only difference is that every verb is awaitable. +Postgres + passthrough run on native async clients (asyncpg / httpx); +mem0, supermemory, and letta are wrapped with a per-instance +`ThreadPoolExecutor`. + +```python +import asyncio +from openmem import AsyncMemory # requires: pip install 'openmem[async]' + +async def main(): + async with AsyncMemory(provider="postgres", + url="postgresql://postgres:postgres@localhost:5432/postgres") as mem: + rec = await mem.add(content="user prefers dark mode", user_id="u1") + hits = await mem.search("dark mode", user_id="u1") + print(hits[0].memory.content) + +asyncio.run(main()) +``` + +Cancellation propagates within 50 ms on the native tier (postgres, +passthrough); the threadwrap tier returns immediately to the awaiter +while the worker thread completes in the background. See +[../specs/005-async-fastapi/quickstart.md](../specs/005-async-fastapi/quickstart.md) +for the full contract. + ## Environment variables | Var | Purpose | diff --git a/sdk-python/openmem/__init__.py b/sdk-python/openmem/__init__.py index 60ec2dc..c376589 100644 --- a/sdk-python/openmem/__init__.py +++ b/sdk-python/openmem/__init__.py @@ -40,11 +40,12 @@ SearchResult, ) -__version__ = "0.1.0" +__version__ = "0.4.0" __all__ = [ "__version__", "Memory", + "AsyncMemory", "MemoryRecord", "MemoryInput", "MemoryUpdate", @@ -66,3 +67,38 @@ "ProviderError", "UnsupportedProviderError", ] + + +# --------------------------------------------------------------------------- +# Lazy `AsyncMemory` import (T019 / FR-026 / contracts §C-EXT-1..3). +# +# `from openmem import AsyncMemory` works iff the `[async]` extra is +# installed (asyncpg + httpx). Without it, the import raises a clear +# `ImportError` whose message contains the exact remediation string. +# Importing `openmem` itself MUST NOT trigger any async dependency +# resolution (C-EXT-3) — that is why this lives in `__getattr__`. +# --------------------------------------------------------------------------- + + +def __getattr__(name: str): # noqa: D401 - module-level hook + if name == "AsyncMemory": + # FR-026 / C-EXT-1..3: the user must learn *now* that the + # `[async]` extras are missing — not deep inside a backend + # call. Eagerly probe `asyncpg` (the only async-only runtime + # dep; httpx is a base requirement) before exposing the class. + try: + import asyncpg # noqa: F401 + except ImportError as exc: + raise ImportError( + "openmem.AsyncMemory requires the async extras. " + "Install with: pip install 'openmem[async]'" + ) from exc + try: + from .async_memory import AsyncMemory as _AsyncMemory + except ImportError as exc: # pragma: no cover - import-time path + raise ImportError( + "openmem.AsyncMemory requires the async extras. " + "Install with: pip install 'openmem[async]'" + ) from exc + return _AsyncMemory + raise AttributeError(f"module 'openmem' has no attribute {name!r}") diff --git a/sdk-python/openmem/adapters/_postgres_sql.py b/sdk-python/openmem/adapters/_postgres_sql.py new file mode 100644 index 0000000..7fec25a --- /dev/null +++ b/sdk-python/openmem/adapters/_postgres_sql.py @@ -0,0 +1,153 @@ +"""Shared postgres SQL fragments and helpers (T007 / M3.2). + +Extracted from ``openmem.adapters.postgres`` so the upcoming +``AsyncPostgresAdapter`` (T015, asyncpg-based) can reuse the **exact same** +SQL strings as the sync adapter — eliminating any drift between the two +backends and keeping `Memory` byte-identical with `AsyncMemory` (SC-008). + +Behavior **must not change** — every existing sync test must still pass. +""" + +from __future__ import annotations + +import base64 +from datetime import datetime +from typing import Any + +from ulid import ULID + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +STD_FIELDS = { + "id", + "content", + "user_id", + "scope", + "tags", + "source", + "confidence", + "valid_from", + "valid_to", + "supersedes", + "embedding_model", + "created_at", + "updated_at", +} + +# Static query templates (psycopg / %s placeholders). + +GET_MEMORY_SQL = "SELECT * FROM memories WHERE id = %s;" + +DELETE_MEMORY_SQL = "DELETE FROM memories WHERE id = %s;" + +INSERT_MEMORY_SQL = """ +INSERT INTO memories ( + id, content, user_id, scope, tags, source, confidence, + valid_from, valid_to, supersedes, embedding_model, + embedding, extensions, created_at +) VALUES ( + %s, %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s, + %s::vector, %s, %s +) +RETURNING *; +""" + +CREATE_EXTENSION_SQL = "CREATE EXTENSION IF NOT EXISTS vector;" + +INDEX_USER_SCOPE_SQL = ( + "CREATE INDEX IF NOT EXISTS idx_memories_user_scope ON memories(user_id, scope);" +) + +INDEX_TAGS_SQL = ( + "CREATE INDEX IF NOT EXISTS idx_memories_tags ON memories USING GIN(tags);" +) + +INDEX_CREATED_AT_SQL = ( + "CREATE INDEX IF NOT EXISTS idx_memories_created_at " + "ON memories(created_at DESC, id DESC);" +) + + +def make_create_table_sql(dim: int) -> str: + """Return the CREATE TABLE statement parameterized by embedding dim.""" + return f""" +CREATE TABLE IF NOT EXISTS memories ( + id TEXT PRIMARY KEY, + content TEXT NOT NULL, + user_id TEXT NOT NULL, + scope TEXT, + tags TEXT[], + source JSONB, + confidence REAL, + valid_from TIMESTAMPTZ, + valid_to TIMESTAMPTZ, + supersedes TEXT[], + embedding_model TEXT, + embedding VECTOR({dim}), + extensions JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ +); +""" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def new_id() -> str: + """Generate a new ULID-suffixed memory id.""" + return f"mem_{ULID()}" + + +def vector_literal(vec: list[float]) -> str: + """Render a Python list as a pgvector literal.""" + return "[" + ",".join(repr(float(x)) for x in vec) + "]" + + +def scope_glob_to_sql_like(scope: str | None) -> str | None: + """Translate an OMP scope glob (``*``) to SQL ``LIKE`` (``%``).""" + if scope is None: + return None + return scope.replace("*", "%") + + +def encode_cursor(created_at: datetime, id_: str) -> str: + """Encode a ``(created_at, id)`` pair as a stable opaque cursor.""" + raw = f"{created_at.isoformat()}|{id_}".encode() + return base64.urlsafe_b64encode(raw).decode("ascii") + + +def decode_cursor(cursor: str) -> tuple[datetime, str]: + """Inverse of :func:`encode_cursor`.""" + raw = base64.urlsafe_b64decode(cursor.encode("ascii")).decode() + ts, id_ = raw.split("|", 1) + return datetime.fromisoformat(ts), id_ + + +def split_extensions(extra: dict[str, Any]) -> dict[str, Any]: + """Extract ``x-`` keys from a free-form dict (Principle V).""" + return {k: v for k, v in extra.items() if k.startswith("x-")} + + +__all__ = [ + "STD_FIELDS", + "GET_MEMORY_SQL", + "DELETE_MEMORY_SQL", + "INSERT_MEMORY_SQL", + "CREATE_EXTENSION_SQL", + "INDEX_USER_SCOPE_SQL", + "INDEX_TAGS_SQL", + "INDEX_CREATED_AT_SQL", + "make_create_table_sql", + "new_id", + "vector_literal", + "scope_glob_to_sql_like", + "encode_cursor", + "decode_cursor", + "split_extensions", +] diff --git a/sdk-python/openmem/adapters/_validation.py b/sdk-python/openmem/adapters/_validation.py new file mode 100644 index 0000000..f89f6ca --- /dev/null +++ b/sdk-python/openmem/adapters/_validation.py @@ -0,0 +1,26 @@ +"""Cross-adapter validation helpers (T008 / M3.2). + +Centralizes input checks that previously lived inline in each sync +adapter so both sync and the upcoming async adapters share one +implementation. +""" + +from __future__ import annotations + +from ..errors import InvalidRequestError + + +def require_user_id(user_id: str | None, *, provider: str) -> str: + """Raise ``InvalidRequestError`` if ``user_id`` is missing or whitespace. + + Returns the (unchanged) ``user_id`` for ergonomic chaining. + + Cross-user broadening defence: every adapter MUST refuse a search/list + that omits ``user_id`` BEFORE issuing any upstream call. + """ + if user_id is None or not str(user_id).strip(): + raise InvalidRequestError("user_id is required", provider=provider) + return user_id + + +__all__ = ["require_user_id"] diff --git a/sdk-python/openmem/adapters/async_base.py b/sdk-python/openmem/adapters/async_base.py new file mode 100644 index 0000000..98e5696 --- /dev/null +++ b/sdk-python/openmem/adapters/async_base.py @@ -0,0 +1,98 @@ +"""Async adapter Protocol — the async mirror of `BaseAdapter`. + +Every async adapter (postgres/passthrough native, threadwrap for sync-only +providers) must satisfy this Protocol structurally. The return types are +identical to the sync `BaseAdapter` so `Memory` and `AsyncMemory` agree +byte-for-byte on outputs (data-model §1 invariant AM-INV-7, SC-008). + +Per Constitution Principle II, every concrete async adapter must pass the +parametrized contract suite at `sdk-python/tests/async/test_async_*.py`. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Protocol, runtime_checkable + +from ..types import ( + AuditEntry, + Capabilities, + ContextBlock, + Memory, + MemoryInput, + MemoryPage, + MemoryUpdate, + SearchResult, +) + + +@runtime_checkable +class AsyncBaseAdapter(Protocol): + """Async mirror of `BaseAdapter`. + + All ten verbs are ``async def``. Return types match the sync surface + so `Memory` (sync) and `AsyncMemory` (async) callers can swap freely. + """ + + async def add(self, memory: MemoryInput) -> Memory: ... + + async def get(self, id: str) -> Memory: ... + + async def update(self, id: str, update: MemoryUpdate) -> Memory: ... + + async def delete(self, id: str) -> None: ... + + async def list( # noqa: A003 — match OMP verb name + self, + user_id: str, + *, + scope: str | None = None, + tag: str | None = None, + since: datetime | None = None, + until: datetime | None = None, + limit: int = 50, + cursor: str | None = None, + ) -> MemoryPage: ... + + async def search( + self, + query: str, + user_id: str, + *, + scope: str | None = None, + limit: int = 10, + min_score: float | None = None, + ) -> list[SearchResult]: ... + + async def context( + self, + query: str, + user_id: str, + *, + scope: str | None = None, + token_budget: int = 500, + ) -> ContextBlock: ... + + async def capabilities(self) -> Capabilities: ... + + async def audit( + self, + user_id: str, + *, + app: str | None = None, + since: datetime | None = None, + limit: int = 100, + ) -> list[AuditEntry]: ... + + async def wait_for_ingest( + self, + ids: list[str], + user_id: str, + *, + timeout: float | None = None, + ) -> None: ... + + async def close(self) -> None: ... + + +__all__ = ["AsyncBaseAdapter"] diff --git a/sdk-python/openmem/adapters/async_passthrough.py b/sdk-python/openmem/adapters/async_passthrough.py new file mode 100644 index 0000000..07caa3c --- /dev/null +++ b/sdk-python/openmem/adapters/async_passthrough.py @@ -0,0 +1,328 @@ +"""Async passthrough adapter (T016 / M3.2). + +Native async mirror of :class:`openmem.adapters.passthrough.PassthroughAdapter`, +implemented on top of ``httpx.AsyncClient``. + +Cancellation contract (contracts/async-memory.md §3, *Native* tier): + +* Every verb is a single ``await self._client.request(...)`` call — + cancelling the awaiter aborts the in-flight request and httpx releases + the underlying socket back to the connection pool within 500 ms (C-CAN-2). +* No ``timeout=None`` is used; the constructor's timeout governs every + request. No ``try/except asyncio.CancelledError`` is anywhere in this + module (cancel must propagate cleanly). +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from urllib.parse import quote + +import httpx + +from ..errors import ( + InvalidRequestError, + OMPError, + ProviderError, + UnsupportedCapabilityError, +) +from ..types import ( + AuditEntry, + Capabilities, + ContextBlock, + Memory, + MemoryInput, + MemoryPage, + MemoryUpdate, + SearchResult, +) +from ._http import decode_omp_error +from ._validation import require_user_id + +__all__ = ["AsyncPassthroughAdapter"] + + +def _make_async_client( + base_url: str, + api_key: str | None, + *, + transport: httpx.AsyncBaseTransport | httpx.MockTransport | None, + timeout: float, +) -> httpx.AsyncClient: + headers: dict[str, str] = { + "Accept": "application/json", + "User-Agent": "openmem-python/0.4.0-async", + } + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + return httpx.AsyncClient( + base_url=base_url.rstrip("/"), + headers=headers, + timeout=timeout, + transport=transport, + follow_redirects=False, + ) + + +class AsyncPassthroughAdapter: + """Forward every async OMP verb to a native OMP HTTP endpoint.""" + + def __init__( + self, + base_url: str, + api_key: str | None = None, + capabilities: Capabilities | None = None, + *, + transport: httpx.AsyncBaseTransport | httpx.MockTransport | None = None, + timeout: float = 30.0, + ) -> None: + self._base_url = base_url.rstrip("/") + self._api_key = api_key + self._capabilities = capabilities + self._transport = transport + self._timeout = timeout + self._client: httpx.AsyncClient | None = None + + # --------------------------------------------------------- lifecycle + + def _ensure_client(self) -> httpx.AsyncClient: + if self._client is None: + self._client = _make_async_client( + self._base_url, + self._api_key, + transport=self._transport, + timeout=self._timeout, + ) + return self._client + + async def close(self) -> None: + client = self._client + if client is not None: + self._client = None + try: + await client.aclose() + except Exception: + pass + + # ------------------------------------------------------------ helpers + + async def _request( + self, + method: str, + path: str, + *, + json: Any = None, + params: dict[str, Any] | None = None, + ) -> httpx.Response: + client = self._ensure_client() + try: + resp = await client.request(method, path, json=json, params=params) + except (httpx.TimeoutException, httpx.ConnectError) as exc: + raise ProviderError(str(exc), provider="passthrough") from exc + # One-redirect rule (mirrors sync passthrough). + if 300 <= resp.status_code < 400: + location = resp.headers.get("location") + if not location: + raise ProviderError( + f"HTTP {resp.status_code} redirect without Location header" + ) + try: + follow = await client.request(method, location) + except (httpx.TimeoutException, httpx.ConnectError) as exc: + raise ProviderError(str(exc), provider="passthrough") from exc + if 300 <= follow.status_code < 400: + raise ProviderError("redirect loop") + return follow + return resp + + def _parse( + self, + resp: httpx.Response, + model: type[Any] | None = None, + ) -> Any: + if 200 <= resp.status_code < 300: + if resp.status_code == 204: + return None + text = resp.text + if not text: + raise ProviderError("empty response", provider="passthrough") + payload = resp.json() + if model is None: + return payload + if isinstance(payload, list): + return [model.model_validate(item) for item in payload] + return model.model_validate(payload) + raise decode_omp_error(resp, provider="passthrough") + + async def _check_verb(self, verb: str) -> None: + caps = await self.capabilities() + if verb not in caps.verbs: + raise UnsupportedCapabilityError( + f"verb {verb!r} not advertised by remote (advertised: " + f"{sorted(caps.verbs)})", + provider="passthrough", + ) + + @staticmethod + def _dump(model: Any) -> dict[str, Any]: + return model.model_dump(mode="json", exclude_none=True) + + @staticmethod + def _qs_dt(value: datetime | None) -> str | None: + return value.isoformat() if value is not None else None + + # ------------------------------------------------------------ verbs + + async def capabilities(self) -> Capabilities: + if self._capabilities is not None: + return self._capabilities + client = self._ensure_client() + try: + resp = await client.get("/capabilities") + except (httpx.TimeoutException, httpx.ConnectError) as exc: + raise UnsupportedCapabilityError( + f"capabilities probe failed: {exc}", provider="passthrough" + ) from exc + if not (200 <= resp.status_code < 300): + raise UnsupportedCapabilityError( + f"endpoint {self._base_url} did not return OMP capabilities " + f"(HTTP {resp.status_code})", + provider="passthrough", + ) + try: + payload = resp.json() + self._capabilities = Capabilities.model_validate(payload) + except Exception as exc: + raise UnsupportedCapabilityError( + f"endpoint {self._base_url} returned malformed capabilities", + provider="passthrough", + ) from exc + return self._capabilities + + async def add(self, memory: MemoryInput) -> Memory: + require_user_id(memory.user_id, provider="passthrough") + await self._check_verb("add") + resp = await self._request("POST", "/memories", json=self._dump(memory)) + return self._parse(resp, Memory) + + async def get(self, id: str) -> Memory: + await self._check_verb("get") + resp = await self._request("GET", f"/memories/{quote(id, safe='')}") + return self._parse(resp, Memory) + + async def update(self, id: str, update: MemoryUpdate) -> Memory: + await self._check_verb("update") + resp = await self._request( + "PATCH", + f"/memories/{quote(id, safe='')}", + json=self._dump(update), + ) + return self._parse(resp, Memory) + + async def delete(self, id: str) -> None: + await self._check_verb("delete") + resp = await self._request("DELETE", f"/memories/{quote(id, safe='')}") + self._parse(resp) + return None + + async def list( # noqa: A003 + self, + user_id: str, + *, + scope: str | None = None, + tag: str | None = None, + since: datetime | None = None, + until: datetime | None = None, + limit: int = 50, + cursor: str | None = None, + ) -> MemoryPage: + require_user_id(user_id, provider="passthrough") + await self._check_verb("list") + params: dict[str, Any] = {"user_id": user_id, "limit": limit} + if scope is not None: + params["scope"] = scope + if tag is not None: + params["tag"] = tag + if since is not None: + params["since"] = self._qs_dt(since) + if until is not None: + params["until"] = self._qs_dt(until) + if cursor is not None: + if not isinstance(cursor, str) or len(cursor) > 256: + raise InvalidRequestError( + "malformed cursor", provider="passthrough" + ) + params["cursor"] = cursor + resp = await self._request("GET", "/memories", params=params) + return self._parse(resp, MemoryPage) + + async def search( + self, + query: str, + user_id: str, + *, + scope: str | None = None, + limit: int = 10, + min_score: float | None = None, + ) -> list[SearchResult]: + require_user_id(user_id, provider="passthrough") + await self._check_verb("search") + body: dict[str, Any] = { + "query": query, + "user_id": user_id, + "limit": limit, + } + if scope is not None: + body["scope"] = scope + if min_score is not None: + body["min_score"] = min_score + resp = await self._request("POST", "/memories/search", json=body) + return self._parse(resp, SearchResult) + + async def context( + self, + query: str, + user_id: str, + *, + scope: str | None = None, + token_budget: int = 500, + ) -> ContextBlock: + require_user_id(user_id, provider="passthrough") + await self._check_verb("context") + body: dict[str, Any] = { + "query": query, + "user_id": user_id, + "token_budget": token_budget, + } + if scope is not None: + body["scope"] = scope + resp = await self._request("POST", "/context", json=body) + return self._parse(resp, ContextBlock) + + async def audit( + self, + user_id: str, + *, + app: str | None = None, + since: datetime | None = None, + limit: int = 100, + ) -> list[AuditEntry]: + require_user_id(user_id, provider="passthrough") + await self._check_verb("audit") + params: dict[str, Any] = {"user_id": user_id, "limit": limit} + if app is not None: + params["app"] = app + if since is not None: + params["since"] = self._qs_dt(since) + resp = await self._request("GET", "/audit", params=params) + return self._parse(resp, AuditEntry) + + async def wait_for_ingest( + self, + ids: list[str], + user_id: str, + *, + timeout: float | None = None, + ) -> None: + return None diff --git a/sdk-python/openmem/adapters/async_postgres.py b/sdk-python/openmem/adapters/async_postgres.py new file mode 100644 index 0000000..7a92454 --- /dev/null +++ b/sdk-python/openmem/adapters/async_postgres.py @@ -0,0 +1,562 @@ +"""Async PostgreSQL + pgvector adapter (T015 / M3.2). + +Native async mirror of :class:`openmem.adapters.postgres.PostgresAdapter`, +implemented on top of ``asyncpg``. Reuses the SQL strings from +:mod:`openmem.adapters._postgres_sql` to guarantee byte-identical +behaviour with the sync adapter (FR-011 / SC-008). + +Cancellation contract (contracts/async-memory.md §3, C-CAN-1..3 — *Native* tier): + +* All connection acquires use ``async with self._pool.acquire() as conn:`` + so cancelling the awaiter triggers ``__aexit__`` and the connection is + released back to the pool within 500 ms (C-CAN-2). +* asyncpg propagates server-side query cancel via the wire protocol on + task cancellation, so :data:`pg_stat_activity` MUST NOT show the + cancelled query 1 s after the awaiter sees ``CancelledError`` (C-CAN-3). +""" + +from __future__ import annotations + +import asyncio +import json +import re +from datetime import datetime, timezone +from typing import Any + +import asyncpg + +from ..errors import InvalidRequestError, NotFoundError, ProviderError +from ..types import ( + AuditEntry, + Capabilities, + CapabilityFeatures, + CapabilityLimits, + ContextBlock, + Memory, + MemoryInput, + MemoryPage, + MemorySource, + MemoryUpdate, + SearchResult, + _Citation, +) +from ._postgres_sql import ( + CREATE_EXTENSION_SQL, + DELETE_MEMORY_SQL, + GET_MEMORY_SQL, + INDEX_CREATED_AT_SQL, + INDEX_TAGS_SQL, + INDEX_USER_SCOPE_SQL, + INSERT_MEMORY_SQL, + decode_cursor as _decode_cursor, + encode_cursor as _encode_cursor, + make_create_table_sql, + new_id as _new_id, + scope_glob_to_sql_like as _scope_glob_to_sql_like, + split_extensions as _split_extensions, + vector_literal as _vector_literal, +) +from ._validation import require_user_id +from .embedder import Embedder, FakeEmbedder + +__all__ = ["AsyncPostgresAdapter"] + + +# --------------------------------------------------------------------------- +# Placeholder translation: shared SQL uses psycopg-style ``%s``; asyncpg +# requires positional ``$1``, ``$2`` markers. Translation is purely +# textual (we never embed user input into the query template, only into +# parameter values), so it is safe. +# --------------------------------------------------------------------------- + + +_PLACEHOLDER_RE = re.compile(r"%s") + + +def _to_asyncpg_sql(sql: str) -> str: + counter = {"n": 0} + + def _sub(_match: re.Match[str]) -> str: + counter["n"] += 1 + return f"${counter['n']}" + + return _PLACEHOLDER_RE.sub(_sub, sql) + + +_INSERT_MEMORY_ASYNCPG = _to_asyncpg_sql(INSERT_MEMORY_SQL) +_GET_MEMORY_ASYNCPG = _to_asyncpg_sql(GET_MEMORY_SQL) +_DELETE_MEMORY_ASYNCPG = _to_asyncpg_sql(DELETE_MEMORY_SQL) + + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + + +class AsyncPostgresAdapter: + """Async OMP adapter backed by asyncpg + pgvector.""" + + def __init__( + self, + url: str, + *, + embedder: Embedder | None = None, + pool_min_size: int = 1, + pool_max_size: int = 10, + pool_timeout: float = 30.0, + ) -> None: + self._url = url + self.embedder: Embedder = embedder or FakeEmbedder() + self._pool_min_size = pool_min_size + self._pool_max_size = pool_max_size + self._pool_timeout = pool_timeout + self._pool: asyncpg.Pool | None = None + self._dim = self.embedder.dim + self._schema_ready = False + self._init_lock = asyncio.Lock() + + # ------------------------------------------------------- pool lifecycle + + async def _ensure_pool(self) -> asyncpg.Pool: + """Lazy pool creation (C-LIFE-1: no I/O in __init__). + + Guarded by an asyncio.Lock so concurrent first-call fan-out + (e.g. ``asyncio.gather(*[mem.add(...) for _ in range(100)])``) + cannot race and trigger N parallel ``create_pool`` attempts — + which would burst-open N×min_size connections and starve the + Postgres ``max_connections`` cap. + """ + if self._pool is not None and self._schema_ready: + return self._pool + async with self._init_lock: + if self._pool is None: + try: + self._pool = await asyncpg.create_pool( + dsn=self._url, + min_size=self._pool_min_size, + max_size=self._pool_max_size, + timeout=self._pool_timeout, + ) + except Exception as e: + raise ProviderError( + f"failed to open async postgres pool: {e}", + provider="postgres", + ) from e + if not self._schema_ready: + await self._ensure_schema() + self._schema_ready = True + assert self._pool is not None + return self._pool + + async def _ensure_schema(self) -> None: + assert self._pool is not None + try: + async with self._pool.acquire() as conn: + await conn.execute(CREATE_EXTENSION_SQL) + await conn.execute(make_create_table_sql(self._dim)) + await conn.execute(INDEX_USER_SCOPE_SQL) + await conn.execute(INDEX_TAGS_SQL) + await conn.execute(INDEX_CREATED_AT_SQL) + except Exception as e: + raise ProviderError( + f"DDL failed: {e}", provider="postgres" + ) from e + + async def close(self) -> None: + """Close the pool. Idempotent.""" + pool = self._pool + if pool is not None: + self._pool = None + try: + await pool.close() + except Exception: + pass + + # ---------------------------------------------------------------- helpers + + def _row_to_memory(self, row: Any) -> Memory: + d = dict(row) + # asyncpg returns JSONB columns as `str`; psycopg returned dicts. + # Normalise so the Memory model receives the same shape. + if isinstance(d.get("source"), str): + d["source"] = json.loads(d["source"]) + if isinstance(d.get("extensions"), str): + d["extensions"] = json.loads(d["extensions"]) + data: dict[str, Any] = { + "id": d["id"], + "content": d["content"], + "user_id": d["user_id"], + "scope": d["scope"], + "tags": d["tags"], + "source": MemorySource(**d["source"]) if d.get("source") else None, + "confidence": d["confidence"], + "valid_from": d["valid_from"], + "valid_to": d["valid_to"], + "supersedes": d["supersedes"], + "embedding_model": d["embedding_model"], + "created_at": d["created_at"], + "updated_at": d["updated_at"], + } + if d.get("extensions"): + data.update(d["extensions"]) + return Memory(**data) + + # ------------------------------------------------------------------ verbs + + async def add(self, memory: MemoryInput) -> Memory: + require_user_id(memory.user_id, provider="postgres") + pool = await self._ensure_pool() + if self.embedder.dim != self._dim: + raise InvalidRequestError( + f"embedder dim {self.embedder.dim} does not match " + f"table dim {self._dim}", + provider="postgres", + ) + embedding = self.embedder.embed([memory.content])[0] + new_id = _new_id() + now = datetime.now(timezone.utc) + extras = _split_extensions(memory.model_extra or {}) + try: + async with pool.acquire() as conn: + row = await conn.fetchrow( + _INSERT_MEMORY_ASYNCPG, + new_id, + memory.content, + memory.user_id, + memory.scope, + memory.tags, + json.dumps(memory.source.model_dump(exclude_none=True)) + if memory.source + else None, + memory.confidence, + memory.valid_from, + memory.valid_to, + memory.supersedes, + self.embedder.model, + _vector_literal(embedding), + json.dumps(extras) if extras else None, + now, + ) + except Exception as e: + raise ProviderError( + f"insert failed: {e}", provider="postgres" + ) from e + assert row is not None + return self._row_to_memory(row) + + async def get(self, id: str) -> Memory: + pool = await self._ensure_pool() + try: + async with pool.acquire() as conn: + row = await conn.fetchrow(_GET_MEMORY_ASYNCPG, id) + except Exception as e: + raise ProviderError( + f"get failed: {e}", provider="postgres" + ) from e + if row is None: + raise NotFoundError( + f"memory {id!r} not found", provider="postgres" + ) + return self._row_to_memory(row) + + async def update(self, id: str, update: MemoryUpdate) -> Memory: + pool = await self._ensure_pool() + sets: list[str] = [] + params: list[Any] = [] + idx = 0 + + def _next() -> str: + nonlocal idx + idx += 1 + return f"${idx}" + + if update.content is not None: + sets.append(f"content = {_next()}") + params.append(update.content) + new_emb = self.embedder.embed([update.content])[0] + sets.append(f"embedding = {_next()}::vector") + params.append(_vector_literal(new_emb)) + sets.append(f"embedding_model = {_next()}") + params.append(self.embedder.model) + if update.scope is not None: + sets.append(f"scope = {_next()}") + params.append(update.scope) + if update.tags is not None: + sets.append(f"tags = {_next()}") + params.append(update.tags) + if update.confidence is not None: + sets.append(f"confidence = {_next()}") + params.append(update.confidence) + if update.valid_to is not None: + sets.append(f"valid_to = {_next()}") + params.append(update.valid_to) + if update.supersedes is not None: + sets.append( + f"supersedes = COALESCE(supersedes, ARRAY[]::TEXT[]) || {_next()}::TEXT[]" + ) + params.append(update.supersedes) + sets.append(f"updated_at = {_next()}") + params.append(datetime.now(timezone.utc)) + + if not sets or len(sets) == 1: # only updated_at + return await self.get(id) + + params.append(id) + sql = ( + f"UPDATE memories SET {', '.join(sets)} " + f"WHERE id = ${idx + 1} RETURNING *;" + ) + try: + async with pool.acquire() as conn: + row = await conn.fetchrow(sql, *params) + except Exception as e: + raise ProviderError( + f"update failed: {e}", provider="postgres" + ) from e + if row is None: + raise NotFoundError( + f"memory {id!r} not found", provider="postgres" + ) + return self._row_to_memory(row) + + async def delete(self, id: str) -> None: + pool = await self._ensure_pool() + try: + async with pool.acquire() as conn: + result = await conn.execute(_DELETE_MEMORY_ASYNCPG, id) + except Exception as e: + raise ProviderError( + f"delete failed: {e}", provider="postgres" + ) from e + # asyncpg `execute` returns a status string like "DELETE 1". + try: + affected = int(result.split()[-1]) + except (ValueError, IndexError): + affected = 0 + if affected == 0: + raise NotFoundError( + f"memory {id!r} not found", provider="postgres" + ) + + async def list( # noqa: A003 + self, + user_id: str, + *, + scope: str | None = None, + tag: str | None = None, + since: datetime | None = None, + until: datetime | None = None, + limit: int = 50, + cursor: str | None = None, + ) -> MemoryPage: + require_user_id(user_id, provider="postgres") + pool = await self._ensure_pool() + clauses: list[str] = [] + params: list[Any] = [] + idx = 0 + + def _next() -> str: + nonlocal idx + idx += 1 + return f"${idx}" + + clauses.append(f"user_id = {_next()}") + params.append(user_id) + if scope is not None: + clauses.append(f"scope LIKE {_next()}") + params.append(_scope_glob_to_sql_like(scope)) + if tag is not None: + clauses.append(f"{_next()} = ANY(tags)") + params.append(tag) + if since is not None: + clauses.append(f"created_at >= {_next()}") + params.append(since) + if until is not None: + clauses.append(f"created_at <= {_next()}") + params.append(until) + if cursor is not None: + ts, last_id = _decode_cursor(cursor) + ph_ts = _next() + ph_id = _next() + clauses.append(f"(created_at, id) < ({ph_ts}, {ph_id})") + params.extend([ts, last_id]) + + ph_lim = _next() + params.append(limit) + sql = ( + "SELECT * FROM memories WHERE " + + " AND ".join(clauses) + + f" ORDER BY created_at DESC, id DESC LIMIT {ph_lim};" + ) + try: + async with pool.acquire() as conn: + rows = await conn.fetch(sql, *params) + except Exception as e: + raise ProviderError( + f"list failed: {e}", provider="postgres" + ) from e + + items = [self._row_to_memory(r) for r in rows] + next_cursor = ( + _encode_cursor(rows[-1]["created_at"], rows[-1]["id"]) + if len(rows) == limit and rows + else None + ) + return MemoryPage(items=items, next_cursor=next_cursor) + + async def search( + self, + query: str, + user_id: str, + *, + scope: str | None = None, + limit: int = 10, + min_score: float | None = None, + ) -> list[SearchResult]: + require_user_id(user_id, provider="postgres") + pool = await self._ensure_pool() + q_emb = self.embedder.embed([query])[0] + idx = 0 + + def _next() -> str: + nonlocal idx + idx += 1 + return f"${idx}" + + ph_vec = _next() + ph_user = _next() + ph_model = _next() + params: list[Any] = [_vector_literal(q_emb), user_id, self.embedder.model] + clauses = [f"user_id = {ph_user}", f"embedding_model = {ph_model}"] + if scope is not None: + clauses.append(f"scope LIKE {_next()}") + params.append(_scope_glob_to_sql_like(scope)) + ph_vec2 = _next() + params.append(_vector_literal(q_emb)) + ph_lim = _next() + params.append(limit) + sql = ( + f"SELECT *, 1 - (embedding <=> {ph_vec}::vector) AS score FROM memories " + f"WHERE {' AND '.join(clauses)} " + f"ORDER BY embedding <=> {ph_vec2}::vector ASC LIMIT {ph_lim};" + ) + try: + async with pool.acquire() as conn: + rows = await conn.fetch(sql, *params) + except Exception as e: + raise ProviderError( + f"search failed: {e}", provider="postgres" + ) from e + + if not rows: + try: + async with pool.acquire() as conn: + if scope is not None: + check = await conn.fetchval( + "SELECT 1 FROM memories WHERE user_id = $1 " + "AND scope LIKE $2 LIMIT 1;", + user_id, + _scope_glob_to_sql_like(scope), + ) + else: + check = await conn.fetchval( + "SELECT 1 FROM memories WHERE user_id = $1 LIMIT 1;", + user_id, + ) + has_other_models = check is not None + except Exception: + has_other_models = False + if has_other_models: + raise InvalidRequestError( + f"no memories indexed with model " + f"{self.embedder.model!r} for this user/scope", + provider="postgres", + ) + return [] + + results: list[SearchResult] = [] + for r in rows: + d = dict(r) + score = float(d.pop("score")) + if min_score is not None and score < min_score: + continue + results.append(SearchResult(memory=self._row_to_memory(d), score=score)) + return results + + async def context( + self, + query: str, + user_id: str, + *, + scope: str | None = None, + token_budget: int = 500, + ) -> ContextBlock: + try: + results = await self.search( + query, user_id, scope=scope, limit=max(1, token_budget // 50) + ) + except InvalidRequestError: + results = [] + + if not results: + return ContextBlock(text="", citations=[], token_count=0) + + lines: list[str] = [] + citations: list[_Citation] = [] + running_chars = 0 + char_budget = token_budget * 4 + for i, r in enumerate(results, start=1): + line = f"[{i}] {r.memory.content}" + if running_chars + len(line) > char_budget and citations: + break + lines.append(line) + citations.append(_Citation(memory_id=r.memory.id, score=r.score)) + running_chars += len(line) + 1 + text = "\n".join(lines) + return ContextBlock( + text=text, citations=citations, token_count=len(text) // 4 + ) + + async def capabilities(self) -> Capabilities: + return Capabilities( + omp_version="0.1", + provider="postgres", + verbs=["add", "search", "get", "update", "delete", "list", "context"], + features=CapabilityFeatures( + vector_search=True, + keyword_search=True, + graph_queries=False, + temporal=True, + scopes="native", + max_content_length=10000, + supports_e2e=False, + supports_audit=False, + supports_supersession=True, + ), + limits=CapabilityLimits(max_search_results=100), + ) + + async def audit( + self, + user_id: str, + *, + app: str | None = None, + since: datetime | None = None, + limit: int = 100, + ) -> list[AuditEntry]: + from ..errors import UnsupportedCapabilityError + + raise UnsupportedCapabilityError( + "audit is not supported by this provider", + provider="postgres", + ) + + async def wait_for_ingest( + self, + ids: list[str], + user_id: str, + *, + timeout: float | None = None, + ) -> None: + # Postgres is read-after-write — no polling needed. + return None diff --git a/sdk-python/openmem/adapters/async_threadwrap.py b/sdk-python/openmem/adapters/async_threadwrap.py new file mode 100644 index 0000000..098d9d3 --- /dev/null +++ b/sdk-python/openmem/adapters/async_threadwrap.py @@ -0,0 +1,231 @@ +"""Async threadwrap adapter (T017 / M3.2). + +Wraps a sync :class:`openmem.adapters.base.BaseAdapter` instance and +forwards every verb through a per-instance +:class:`concurrent.futures.ThreadPoolExecutor`. Used for providers +without a native async client (mem0, supermemory, letta). + +Cancellation contract (contracts/async-memory.md §3, *Best-effort* tier): + +* The awaiter receives ``asyncio.CancelledError`` immediately when + cancelled (C-CAN-4) — Python's ``loop.run_in_executor`` already + honours that contract for the caller. +* The worker thread keeps running to completion (Python threads are not + forcibly killable). When the orphaned call eventually completes, an + ``add_done_callback`` logs at ``logging.DEBUG`` for visibility (T029, + not a hard requirement). +""" + +from __future__ import annotations + +import asyncio +import logging +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from functools import partial +from typing import Any + +from ..types import ( + AuditEntry, + Capabilities, + ContextBlock, + Memory, + MemoryInput, + MemoryPage, + MemoryUpdate, + SearchResult, +) +from .base import BaseAdapter + +__all__ = ["AsyncThreadwrapAdapter"] + +_LOG = logging.getLogger("openmem.async.threadwrap") + + +class AsyncThreadwrapAdapter: + """Async facade over a sync `BaseAdapter` via a thread pool.""" + + def __init__( + self, + sync_adapter: BaseAdapter, + *, + max_workers: int | None = None, + provider_name: str = "threadwrap", + ) -> None: + self._sync = sync_adapter + self._provider = provider_name + self._executor: ThreadPoolExecutor | None = None + self._max_workers = max_workers + self._closed = False + + # ------------------------------------------------------- lifecycle + + def _ensure_executor(self) -> ThreadPoolExecutor: + if self._executor is None: + self._executor = ThreadPoolExecutor( + max_workers=self._max_workers, + thread_name_prefix=f"omp-{self._provider}", + ) + return self._executor + + async def _run(self, verb: str, fn, *args, **kwargs) -> Any: + executor = self._ensure_executor() + # Submit directly so we can attach a done-callback to the + # underlying ``concurrent.futures.Future`` — that future fires + # only when the *worker thread* completes, even if the awaiter + # was cancelled long before. (The asyncio.Future returned by + # ``loop.run_in_executor`` would fire its callbacks immediately + # on cancellation and miss the orphan completion entirely.) + cf_future = executor.submit(partial(fn, *args, **kwargs)) + + # Visibility hook for orphaned calls after cancellation (C-CAN-4). + provider = self._provider + + def _on_done(fut) -> None: + try: + exc = fut.exception() + except Exception: # pragma: no cover - defensive + return + if exc is None: + _LOG.debug( + "orphan call completed after cancellation: provider=%s verb=%s", + provider, + verb, + ) + else: + _LOG.debug( + "orphan call failed after cancellation: provider=%s verb=%s err=%s", + provider, + verb, + type(exc).__name__, + ) + + cf_future.add_done_callback(_on_done) + return await asyncio.wrap_future(cf_future) + + async def close(self) -> None: + if self._closed: + return + self._closed = True + executor = self._executor + # Always close the underlying sync adapter even if executor never spun up. + try: + sync_close = getattr(self._sync, "close", None) + if callable(sync_close): + if executor is None: + sync_close() + else: + loop = asyncio.get_running_loop() + await loop.run_in_executor(executor, sync_close) + except Exception: + pass + if executor is not None: + executor.shutdown(wait=False, cancel_futures=True) + + # ------------------------------------------------------------ verbs + + async def add(self, memory: MemoryInput) -> Memory: + return await self._run("add", self._sync.add, memory) + + async def get(self, id: str) -> Memory: + return await self._run("get", self._sync.get, id) + + async def update(self, id: str, update: MemoryUpdate) -> Memory: + return await self._run("update", self._sync.update, id, update) + + async def delete(self, id: str) -> None: + return await self._run("delete", self._sync.delete, id) + + async def list( # noqa: A003 + self, + user_id: str, + *, + scope: str | None = None, + tag: str | None = None, + since: datetime | None = None, + until: datetime | None = None, + limit: int = 50, + cursor: str | None = None, + ) -> MemoryPage: + return await self._run( + "list", + self._sync.list, + user_id, + scope=scope, + tag=tag, + since=since, + until=until, + limit=limit, + cursor=cursor, + ) + + async def search( + self, + query: str, + user_id: str, + *, + scope: str | None = None, + limit: int = 10, + min_score: float | None = None, + ) -> list[SearchResult]: + return await self._run( + "search", + self._sync.search, + query, + user_id, + scope=scope, + limit=limit, + min_score=min_score, + ) + + async def context( + self, + query: str, + user_id: str, + *, + scope: str | None = None, + token_budget: int = 500, + ) -> ContextBlock: + return await self._run( + "context", + self._sync.context, + query, + user_id, + scope=scope, + token_budget=token_budget, + ) + + async def audit( + self, + user_id: str, + *, + app: str | None = None, + since: datetime | None = None, + limit: int = 100, + ) -> list[AuditEntry]: + return await self._run( + "audit", + self._sync.audit, + user_id, + app=app, + since=since, + limit=limit, + ) + + async def capabilities(self) -> Capabilities: + return await self._run("capabilities", self._sync.capabilities) + + async def wait_for_ingest( + self, + ids: list[str], + user_id: str, + *, + timeout: float | None = None, + ) -> None: + return await self._run( + "wait_for_ingest", + self._sync.wait_for_ingest, + ids, + user_id, + timeout=timeout, + ) diff --git a/sdk-python/openmem/adapters/letta.py b/sdk-python/openmem/adapters/letta.py index 4ff8633..02766ea 100644 --- a/sdk-python/openmem/adapters/letta.py +++ b/sdk-python/openmem/adapters/letta.py @@ -48,6 +48,7 @@ SearchResult, _Citation, ) +from ._validation import require_user_id from .base import BaseAdapter _LOG = logging.getLogger(__name__) @@ -332,8 +333,7 @@ def _check_verb(self, verb: str) -> None: def add(self, memory: MemoryInput) -> Memory: self._check_verb("add") - if not memory.user_id or not str(memory.user_id).strip(): - raise InvalidRequestError("user_id is required", provider="letta") + require_user_id(memory.user_id, provider="letta") agent_id = self._agent_for(memory.user_id) # M2.1: live letta `passages.create` only persists `text=` and # `tags=` — there is NO `metadata=` parameter (verified against @@ -436,8 +436,7 @@ def list( # noqa: A003 cursor: str | None = None, ) -> MemoryPage: self._check_verb("list") - if not user_id or not str(user_id).strip(): - raise InvalidRequestError("user_id is required", provider="letta") + require_user_id(user_id, provider="letta") agent_id = self._agent_for(user_id) # M2.1: Letta paginates by `after=` (NOT by page number). # Decode the OMP cursor into a letta-native passage id so the upstream @@ -496,8 +495,7 @@ def search( min_score: float | None = None, ) -> list[SearchResult]: self._check_verb("search") - if not user_id or not str(user_id).strip(): - raise InvalidRequestError("user_id is required", provider="letta") + require_user_id(user_id, provider="letta") agent_id = self._agent_for(user_id) try: # M2.1: top_k=limit (NOT limit=). Tag filtering deferred (FR-115). diff --git a/sdk-python/openmem/adapters/mem0.py b/sdk-python/openmem/adapters/mem0.py index 6c601af..8b83d17 100644 --- a/sdk-python/openmem/adapters/mem0.py +++ b/sdk-python/openmem/adapters/mem0.py @@ -46,6 +46,7 @@ _Citation, ) from . import _cursor, _ingest +from ._validation import require_user_id from .base import BaseAdapter @@ -645,8 +646,7 @@ def search( ) -> list[SearchResult]: # Pre-flight: empty user_id MUST raise BEFORE any upstream call to # prevent accidental cross-user broadening (FR-104 / data-model.md §3). - if not user_id or not str(user_id).strip(): - raise InvalidRequestError("user_id is required", provider="mem0") + require_user_id(user_id, provider="mem0") try: # mem0 v2 requires `filters={"user_id": ...}` — passing # `user_id=` as a top-level kwarg is rejected since 2.x. diff --git a/sdk-python/openmem/adapters/postgres.py b/sdk-python/openmem/adapters/postgres.py index 7967a73..111a7a6 100644 --- a/sdk-python/openmem/adapters/postgres.py +++ b/sdk-python/openmem/adapters/postgres.py @@ -19,7 +19,6 @@ from __future__ import annotations -import base64 import json from datetime import datetime, timezone from typing import Any @@ -28,7 +27,6 @@ import psycopg_pool from psycopg.rows import dict_row from psycopg.types.json import Json -from ulid import ULID from ..errors import InvalidRequestError, NotFoundError, ProviderError from ..types import ( @@ -44,59 +42,31 @@ SearchResult, _Citation, ) +from ._postgres_sql import ( + CREATE_EXTENSION_SQL, + DELETE_MEMORY_SQL, + GET_MEMORY_SQL, + INDEX_CREATED_AT_SQL, + INDEX_TAGS_SQL, + INDEX_USER_SCOPE_SQL, + INSERT_MEMORY_SQL, + STD_FIELDS as _STD_FIELDS, + decode_cursor as _decode_cursor, + encode_cursor as _encode_cursor, + make_create_table_sql, + new_id as _new_id, + scope_glob_to_sql_like as _scope_glob_to_sql_like, + split_extensions as _split_extensions, + vector_literal as _vector_literal, +) from .base import BaseAdapter from .embedder import Embedder, FakeEmbedder # --------------------------------------------------------------------------- -# Constants +# Constants (re-exported for backward compatibility) # --------------------------------------------------------------------------- -_STD_FIELDS = { - "id", - "content", - "user_id", - "scope", - "tags", - "source", - "confidence", - "valid_from", - "valid_to", - "supersedes", - "embedding_model", - "created_at", - "updated_at", -} - - -def _new_id() -> str: - return f"mem_{ULID()}" - - -def _vector_literal(vec: list[float]) -> str: - """Render a Python list as a pgvector literal.""" - return "[" + ",".join(repr(float(x)) for x in vec) + "]" - - -def _scope_glob_to_sql_like(scope: str | None) -> str | None: - if scope is None: - return None - return scope.replace("*", "%") - - -def _encode_cursor(created_at: datetime, id_: str) -> str: - raw = f"{created_at.isoformat()}|{id_}".encode() - return base64.urlsafe_b64encode(raw).decode("ascii") - - -def _decode_cursor(cursor: str) -> tuple[datetime, str]: - raw = base64.urlsafe_b64decode(cursor.encode("ascii")).decode() - ts, id_ = raw.split("|", 1) - return datetime.fromisoformat(ts), id_ - - -def _split_extensions(extra: dict[str, Any]) -> dict[str, Any]: - """Extract ``x-`` keys from a free-form dict.""" - return {k: v for k, v in extra.items() if k.startswith("x-")} +__all__ = ["PostgresAdapter"] # --------------------------------------------------------------------------- @@ -147,40 +117,11 @@ def _ensure_schema(self) -> None: try: with self._pool.connection() as conn: with conn.cursor() as cur: - cur.execute("CREATE EXTENSION IF NOT EXISTS vector;") - cur.execute( - f""" - CREATE TABLE IF NOT EXISTS memories ( - id TEXT PRIMARY KEY, - content TEXT NOT NULL, - user_id TEXT NOT NULL, - scope TEXT, - tags TEXT[], - source JSONB, - confidence REAL, - valid_from TIMESTAMPTZ, - valid_to TIMESTAMPTZ, - supersedes TEXT[], - embedding_model TEXT, - embedding VECTOR({self._dim}), - extensions JSONB, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ - ); - """ - ) - cur.execute( - "CREATE INDEX IF NOT EXISTS idx_memories_user_scope " - "ON memories(user_id, scope);" - ) - cur.execute( - "CREATE INDEX IF NOT EXISTS idx_memories_tags " - "ON memories USING GIN(tags);" - ) - cur.execute( - "CREATE INDEX IF NOT EXISTS idx_memories_created_at " - "ON memories(created_at DESC, id DESC);" - ) + cur.execute(CREATE_EXTENSION_SQL) + cur.execute(make_create_table_sql(self._dim)) + cur.execute(INDEX_USER_SCOPE_SQL) + cur.execute(INDEX_TAGS_SQL) + cur.execute(INDEX_CREATED_AT_SQL) conn.commit() except psycopg_pool.PoolTimeout as e: raise ProviderError( @@ -234,18 +175,7 @@ def add(self, memory: MemoryInput) -> Memory: with self._pool.connection() as conn: with conn.cursor(row_factory=dict_row) as cur: cur.execute( - """ - INSERT INTO memories ( - id, content, user_id, scope, tags, source, confidence, - valid_from, valid_to, supersedes, embedding_model, - embedding, extensions, created_at - ) VALUES ( - %s, %s, %s, %s, %s, %s, %s, - %s, %s, %s, %s, - %s::vector, %s, %s - ) - RETURNING *; - """, + INSERT_MEMORY_SQL, ( new_id, memory.content, @@ -284,7 +214,7 @@ def get(self, id: str) -> Memory: try: with self._pool.connection() as conn: with conn.cursor(row_factory=dict_row) as cur: - cur.execute("SELECT * FROM memories WHERE id = %s;", (id,)) + cur.execute(GET_MEMORY_SQL, (id,)) row = cur.fetchone() if row is None: raise NotFoundError( @@ -370,7 +300,7 @@ def delete(self, id: str) -> None: try: with self._pool.connection() as conn: with conn.cursor() as cur: - cur.execute("DELETE FROM memories WHERE id = %s;", (id,)) + cur.execute(DELETE_MEMORY_SQL, (id,)) affected = cur.rowcount conn.commit() if affected == 0: diff --git a/sdk-python/openmem/adapters/supermemory.py b/sdk-python/openmem/adapters/supermemory.py index 39ccc96..b79e45c 100644 --- a/sdk-python/openmem/adapters/supermemory.py +++ b/sdk-python/openmem/adapters/supermemory.py @@ -60,6 +60,7 @@ from . import _cursor, _ingest from ._http import follow_one_redirect, make_client from .base import BaseAdapter +from ._validation import require_user_id DEFAULT_BASE_URL = "https://api.supermemory.ai/v3" @@ -459,8 +460,7 @@ def search( ) -> list[SearchResult]: self._check_verb("search") # Pre-flight: empty user_id MUST raise BEFORE any upstream call. - if not user_id or not str(user_id).strip(): - raise InvalidRequestError("user_id is required", provider="supermemory") + require_user_id(user_id, provider="supermemory") body: dict[str, Any] = { "q": query, "limit": limit, diff --git a/sdk-python/openmem/async_memory.py b/sdk-python/openmem/async_memory.py new file mode 100644 index 0000000..acb06c0 --- /dev/null +++ b/sdk-python/openmem/async_memory.py @@ -0,0 +1,368 @@ +"""User-facing :class:`AsyncMemory` facade (T018 / M3.2). + +Async mirror of :class:`openmem.memory.Memory`. The constructor +performs **zero blocking I/O** (data-model AM-INV-3 / C-LIFE-1) — pool +and HTTP client are built lazily on first verb call or on +``__aenter__``. + +Provider routing: + +* ``postgres`` → :class:`AsyncPostgresAdapter` (asyncpg, native cancel) +* ``passthrough`` → :class:`AsyncPassthroughAdapter` (httpx async, native cancel) +* ``mem0`` / ``supermemory`` / ``letta`` → :class:`AsyncThreadwrapAdapter` + wrapping the corresponding sync adapter (best-effort cancel) + +Cross-loop safety (C-LOOP-1): the loop id is captured on the first verb +call. A subsequent verb on a different loop raises +``RuntimeError("AsyncMemory is bound to a different event loop")`` +*before* any backend call. +""" + +from __future__ import annotations + +import asyncio +from datetime import datetime +from typing import Any + +from .adapters.async_base import AsyncBaseAdapter +from .errors import UnsupportedProviderError +from .types import ( + AuditEntry, + Capabilities, + ContextBlock, + Memory as _MemoryRecord, + MemoryInput, + MemoryPage, + MemorySource, + MemoryUpdate, + SearchResult, +) + +__all__ = ["AsyncMemory"] + + +_NATIVE_PROVIDERS = ("postgres", "passthrough") +_THREADWRAP_PROVIDERS = ("mem0", "supermemory", "letta") + + +def _resolve_async_adapter( + provider: str, + *, + executor_max_workers: int | None, + **config: Any, +) -> AsyncBaseAdapter: + """Return the concrete async adapter for ``provider``.""" + if provider == "postgres": + from .adapters.async_postgres import AsyncPostgresAdapter + from .adapters.embedder import FakeEmbedder, OpenAIEmbedder + + embedder = config.pop("embedder", None) + if embedder is None: + embedder = ( + OpenAIEmbedder() + if config.pop("use_openai", False) + else FakeEmbedder() + ) + url = config.pop("url", None) or config.pop("dsn", None) + if not url: + raise ValueError("postgres provider requires url=...") + return AsyncPostgresAdapter(url=url, embedder=embedder, **config) + + if provider == "passthrough": + from .adapters.async_passthrough import AsyncPassthroughAdapter + + base_url = config.pop("base_url", None) + if not base_url: + raise ValueError("passthrough provider requires base_url=...") + return AsyncPassthroughAdapter( + base_url=base_url, + api_key=config.pop("api_key", None), + transport=config.pop("transport", None), + timeout=config.pop("timeout", 30.0), + capabilities=config.pop("capabilities", None), + ) + + if provider in _THREADWRAP_PROVIDERS: + from .adapters.async_threadwrap import AsyncThreadwrapAdapter + + # Build the sync adapter DIRECTLY (do NOT go through + # `openmem.memory._resolve_adapter`). The shared resolver pops + # `base_url` and runs a passthrough auto-probe before falling + # through, which corrupts test transports that target a non-OMP + # endpoint URL. Sync tests sidestep `_resolve_adapter` for the + # same reason — see `tests/conftest.py::supermemory_adapter`. + sync_adapter = _build_sync_adapter_direct(provider, **config) + return AsyncThreadwrapAdapter( + sync_adapter, + max_workers=executor_max_workers, + provider_name=provider, + ) + + raise UnsupportedProviderError( + f"unknown async provider {provider!r}" + ) + + +def _build_sync_adapter_direct(provider: str, **config: Any): + """Construct a translation sync adapter without auto-probe side effects.""" + api_key = config.pop("api_key", None) + if not api_key: + raise ValueError(f"{provider} provider requires api_key=...") + if provider == "mem0": + from .adapters.mem0 import Mem0Adapter + + return Mem0Adapter( + api_key=api_key, + host=config.pop("host", "https://api.mem0.ai"), + client=config.pop("client", None), + ) + if provider == "supermemory": + from .adapters.supermemory import DEFAULT_BASE_URL, SupermemoryAdapter + + return SupermemoryAdapter( + api_key=api_key, + base_url=config.pop("base_url", DEFAULT_BASE_URL), + transport=config.pop("transport", None), + ) + if provider == "letta": + from .adapters.letta import LettaAdapter + + return LettaAdapter( + api_key=api_key, + base_url=config.pop("base_url", None), + client=config.pop("client", None), + ) + raise UnsupportedProviderError( + f"unknown threadwrap provider {provider!r}" + ) + + +class AsyncMemory: + """Async OMP client. Mirrors :class:`openmem.Memory` 1:1. + + Example:: + + from openmem import AsyncMemory + async with AsyncMemory(provider="postgres", url="postgres://...") as mem: + m = await mem.add(content="user prefers pnpm", user_id="u1") + for r in await mem.search("package manager", user_id="u1"): + print(r.memory.content, r.score) + """ + + def __init__( + self, + provider: str = "postgres", + **config: Any, + ) -> None: + # `executor_max_workers` is the only async-only kwarg; pull it + # out and forward to the threadwrap adapter. Native adapters + # silently ignore it (AM-INV-7). + executor_max_workers = config.pop("executor_max_workers", None) + self._adapter: AsyncBaseAdapter = _resolve_async_adapter( + provider, + executor_max_workers=executor_max_workers, + **config, + ) + self._capabilities: Capabilities | None = None + self._closed: bool = False + self._loop_id: int | None = None + + # --------------------------------------------------------- guards + + def _check_open(self) -> None: + if self._closed: + raise RuntimeError("AsyncMemory is closed") + + def _check_loop(self) -> None: + try: + current = id(asyncio.get_running_loop()) + except RuntimeError: + # No running loop — we can't bind yet; the verb call itself + # will fail naturally if it needs one. + return + if self._loop_id is None: + self._loop_id = current + return + if self._loop_id != current: + raise RuntimeError( + "AsyncMemory is bound to a different event loop" + ) + + # --------------------------------------------------------- lifecycle + + async def __aenter__(self) -> "AsyncMemory": + # Eagerly bind the loop so subsequent verbs detect cross-loop misuse. + self._check_loop() + return self + + async def __aexit__(self, *exc: Any) -> None: + await self.close() + + async def close(self) -> None: + """Close the adapter. Idempotent (C-LIFE-5).""" + if self._closed: + return + self._closed = True + try: + await self._adapter.close() + except Exception: + pass + + # ------------------------------------------------- verb passthrough + + async def add( + self, + *, + content: str, + user_id: str, + scope: str | None = None, + tags: list[str] | None = None, + source: MemorySource | dict[str, Any] | None = None, + confidence: float | None = None, + valid_from: datetime | None = None, + valid_to: datetime | None = None, + supersedes: list[str] | None = None, + **extensions: Any, + ) -> _MemoryRecord: + self._check_open() + self._check_loop() + # C-ERR-3: validate user_id BEFORE building MemoryInput so + # threadwrap providers (mem0, supermemory, letta) cannot reach a + # backend with empty user_id and leak across users. + from .adapters._validation import require_user_id + + require_user_id(user_id, provider="async") + if isinstance(source, dict): + source = MemorySource(**source) + payload = MemoryInput( + content=content, + user_id=user_id, + scope=scope, + tags=tags, + source=source, + confidence=confidence, + valid_from=valid_from, + valid_to=valid_to, + supersedes=supersedes, + **extensions, + ) + return await self._adapter.add(payload) + + async def get(self, id: str) -> _MemoryRecord: + self._check_open() + self._check_loop() + return await self._adapter.get(id) + + async def update( + self, + id: str, + *, + content: str | None = None, + scope: str | None = None, + tags: list[str] | None = None, + confidence: float | None = None, + valid_to: datetime | None = None, + supersedes: list[str] | None = None, + ) -> _MemoryRecord: + self._check_open() + self._check_loop() + return await self._adapter.update( + id, + MemoryUpdate( + content=content, + scope=scope, + tags=tags, + confidence=confidence, + valid_to=valid_to, + supersedes=supersedes, + ), + ) + + async def delete(self, id: str) -> None: + self._check_open() + self._check_loop() + await self._adapter.delete(id) + + async def list( # noqa: A003 + self, + user_id: str, + *, + scope: str | None = None, + tag: str | None = None, + since: datetime | None = None, + until: datetime | None = None, + limit: int = 50, + cursor: str | None = None, + ) -> MemoryPage: + self._check_open() + self._check_loop() + return await self._adapter.list( + user_id, + scope=scope, + tag=tag, + since=since, + until=until, + limit=limit, + cursor=cursor, + ) + + async def search( + self, + query: str, + user_id: str, + *, + scope: str | None = None, + limit: int = 10, + min_score: float | None = None, + ) -> list[SearchResult]: + self._check_open() + self._check_loop() + return await self._adapter.search( + query, user_id, scope=scope, limit=limit, min_score=min_score + ) + + async def context( + self, + query: str, + user_id: str, + *, + scope: str | None = None, + token_budget: int = 500, + ) -> ContextBlock: + self._check_open() + self._check_loop() + return await self._adapter.context( + query, user_id, scope=scope, token_budget=token_budget + ) + + async def audit( + self, + user_id: str, + *, + app: str | None = None, + since: datetime | None = None, + limit: int = 100, + ) -> list[AuditEntry]: + self._check_open() + self._check_loop() + return await self._adapter.audit( + user_id, app=app, since=since, limit=limit + ) + + async def capabilities(self) -> Capabilities: + self._check_open() + self._check_loop() + if self._capabilities is None: + self._capabilities = await self._adapter.capabilities() + return self._capabilities + + async def wait_for_ingest( + self, + ids: list[str], + user_id: str, + *, + timeout: float | None = None, + ) -> None: + self._check_open() + self._check_loop() + await self._adapter.wait_for_ingest(ids, user_id, timeout=timeout) diff --git a/sdk-python/pyproject.toml b/sdk-python/pyproject.toml index 21c8697..6cca87a 100644 --- a/sdk-python/pyproject.toml +++ b/sdk-python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "openmem" -version = "0.1.0" +version = "0.4.0" description = "Open Memory Protocol (OMP) — one API for any AI memory provider." readme = "README.md" requires-python = ">=3.11" @@ -33,10 +33,13 @@ openai = ["openai>=1.0"] mem0 = ["mem0ai>=2.0,<3"] supermemory = [] letta = ["letta-client>=1.10"] +async = ["asyncpg>=0.29", "httpx>=0.27"] +server = ["openmem[async]", "fastapi>=0.115", "uvicorn[standard]>=0.30"] dev = [ "pytest>=7", "pytest-cov", "pytest-timeout>=2.3", + "pytest-asyncio>=0.24", "testcontainers[postgres]>=4", "openapi-spec-validator>=0.7", "PyYAML>=6", @@ -60,6 +63,8 @@ packages = ["openmem"] testpaths = ["tests"] addopts = "--cov=openmem --cov-report=term-missing --cov-fail-under=85" timeout = 30 +asyncio_mode = "auto" markers = [ "live: tests that hit real provider APIs (auto-skipped unless OMP_LIVE=1; budget up to 90s)", + "slow: tests that take >30s (e.g. spin up a throwaway venv); may be deselected on fast CI runs", ] diff --git a/sdk-python/tests/async/__init__.py b/sdk-python/tests/async/__init__.py new file mode 100644 index 0000000..31b0328 --- /dev/null +++ b/sdk-python/tests/async/__init__.py @@ -0,0 +1 @@ +"""Async-adapter / AsyncMemory test suite (M3.2 PR-A).""" diff --git a/sdk-python/tests/async/_signatures_baseline.json b/sdk-python/tests/async/_signatures_baseline.json new file mode 100644 index 0000000..308168d --- /dev/null +++ b/sdk-python/tests/async/_signatures_baseline.json @@ -0,0 +1,354 @@ +{ + "__init__": [ + [ + "self", + "POSITIONAL_OR_KEYWORD", + "", + "" + ], + [ + "provider", + "POSITIONAL_OR_KEYWORD", + "'postgres'", + "str" + ], + [ + "config", + "VAR_KEYWORD", + "", + "Any" + ] + ], + "add": [ + [ + "self", + "POSITIONAL_OR_KEYWORD", + "", + "" + ], + [ + "content", + "KEYWORD_ONLY", + "", + "str" + ], + [ + "user_id", + "KEYWORD_ONLY", + "", + "str" + ], + [ + "scope", + "KEYWORD_ONLY", + "None", + "str | None" + ], + [ + "tags", + "KEYWORD_ONLY", + "None", + "list[str] | None" + ], + [ + "source", + "KEYWORD_ONLY", + "None", + "MemorySource | dict[str, Any] | None" + ], + [ + "confidence", + "KEYWORD_ONLY", + "None", + "float | None" + ], + [ + "valid_from", + "KEYWORD_ONLY", + "None", + "datetime | None" + ], + [ + "valid_to", + "KEYWORD_ONLY", + "None", + "datetime | None" + ], + [ + "supersedes", + "KEYWORD_ONLY", + "None", + "list[str] | None" + ], + [ + "extensions", + "VAR_KEYWORD", + "", + "Any" + ] + ], + "audit": [ + [ + "self", + "POSITIONAL_OR_KEYWORD", + "", + "" + ], + [ + "user_id", + "POSITIONAL_OR_KEYWORD", + "", + "str" + ], + [ + "app", + "KEYWORD_ONLY", + "None", + "str | None" + ], + [ + "since", + "KEYWORD_ONLY", + "None", + "datetime | None" + ], + [ + "limit", + "KEYWORD_ONLY", + "100", + "int" + ] + ], + "capabilities": [ + [ + "self", + "POSITIONAL_OR_KEYWORD", + "", + "" + ] + ], + "context": [ + [ + "self", + "POSITIONAL_OR_KEYWORD", + "", + "" + ], + [ + "query", + "POSITIONAL_OR_KEYWORD", + "", + "str" + ], + [ + "user_id", + "POSITIONAL_OR_KEYWORD", + "", + "str" + ], + [ + "scope", + "KEYWORD_ONLY", + "None", + "str | None" + ], + [ + "token_budget", + "KEYWORD_ONLY", + "500", + "int" + ] + ], + "delete": [ + [ + "self", + "POSITIONAL_OR_KEYWORD", + "", + "" + ], + [ + "id", + "POSITIONAL_OR_KEYWORD", + "", + "str" + ] + ], + "get": [ + [ + "self", + "POSITIONAL_OR_KEYWORD", + "", + "" + ], + [ + "id", + "POSITIONAL_OR_KEYWORD", + "", + "str" + ] + ], + "list": [ + [ + "self", + "POSITIONAL_OR_KEYWORD", + "", + "" + ], + [ + "user_id", + "POSITIONAL_OR_KEYWORD", + "", + "str" + ], + [ + "scope", + "KEYWORD_ONLY", + "None", + "str | None" + ], + [ + "tag", + "KEYWORD_ONLY", + "None", + "str | None" + ], + [ + "since", + "KEYWORD_ONLY", + "None", + "datetime | None" + ], + [ + "until", + "KEYWORD_ONLY", + "None", + "datetime | None" + ], + [ + "limit", + "KEYWORD_ONLY", + "50", + "int" + ], + [ + "cursor", + "KEYWORD_ONLY", + "None", + "str | None" + ] + ], + "search": [ + [ + "self", + "POSITIONAL_OR_KEYWORD", + "", + "" + ], + [ + "query", + "POSITIONAL_OR_KEYWORD", + "", + "str" + ], + [ + "user_id", + "POSITIONAL_OR_KEYWORD", + "", + "str" + ], + [ + "scope", + "KEYWORD_ONLY", + "None", + "str | None" + ], + [ + "limit", + "KEYWORD_ONLY", + "10", + "int" + ], + [ + "min_score", + "KEYWORD_ONLY", + "None", + "float | None" + ] + ], + "update": [ + [ + "self", + "POSITIONAL_OR_KEYWORD", + "", + "" + ], + [ + "id", + "POSITIONAL_OR_KEYWORD", + "", + "str" + ], + [ + "content", + "KEYWORD_ONLY", + "None", + "str | None" + ], + [ + "scope", + "KEYWORD_ONLY", + "None", + "str | None" + ], + [ + "tags", + "KEYWORD_ONLY", + "None", + "list[str] | None" + ], + [ + "confidence", + "KEYWORD_ONLY", + "None", + "float | None" + ], + [ + "valid_to", + "KEYWORD_ONLY", + "None", + "datetime | None" + ], + [ + "supersedes", + "KEYWORD_ONLY", + "None", + "list[str] | None" + ] + ], + "wait_for_ingest": [ + [ + "self", + "POSITIONAL_OR_KEYWORD", + "", + "" + ], + [ + "ids", + "POSITIONAL_OR_KEYWORD", + "", + "list[str]" + ], + [ + "user_id", + "POSITIONAL_OR_KEYWORD", + "", + "str" + ], + [ + "timeout", + "KEYWORD_ONLY", + "None", + "float | None" + ] + ] +} diff --git a/sdk-python/tests/async/conftest.py b/sdk-python/tests/async/conftest.py new file mode 100644 index 0000000..8603822 --- /dev/null +++ b/sdk-python/tests/async/conftest.py @@ -0,0 +1,203 @@ +"""Pytest fixtures for the async-adapter / `AsyncMemory` test suite (M3.2 PR-A). + +Mirrors the M2.1 sync pattern in :mod:`tests.conftest`: + +* Reuses the session-scoped ``pg_url`` and module-scoped ``postgres_adapter`` + fixtures from the sync conftest (they auto-discover via pytest's + parent-directory walk). +* Provides an ``async_memory_factory`` fixture — a coroutine factory + ``_make_async_memory(provider, **kw)`` that constructs an + :class:`openmem.AsyncMemory`, registers an async finalizer + (``await mem.close()``), and returns the live instance. +* Provides a ``live_finalizer`` fixture that tracks ids returned by + ``mem.add(...)`` so live-mode tests can ``await mem.delete(id)`` at + teardown without leaking remote state. +* Provides a parametrized ``async_memory`` fixture covering all 5 + providers (postgres, passthrough, mem0, supermemory, letta) using the + same dispatch pattern as the sync ``adapter`` fixture. + +Live-mode gating mirrors :func:`tests.conftest._is_live_mode_active` — +``OMP_LIVE`` must be exactly ``"1"`` *and* the per-provider +``_API_KEY`` must be non-empty after ``.strip()``. Otherwise +the provider runs in mock mode against the in-process Postgres backend +via the existing sync mock-transport shims (passthrough, supermemory, +mem0, letta). +""" + +from __future__ import annotations + +import logging +import os +from typing import Any + +import pytest +import pytest_asyncio + +# Re-use the sync conftest helpers (loaded automatically by pytest's +# parent-directory walk) for live-mode detection. +from tests.conftest import _is_live_mode_active # noqa: E402 + +_LIVE_LOG = logging.getLogger("openmem.tests.async.live_mode") + + +# --------------------------------------------------------------------------- +# Async-memory factory +# --------------------------------------------------------------------------- + + +@pytest_asyncio.fixture +async def async_memory_factory( + request, + pg_url, + _omp_mock_server, + postgres_adapter, +): + """Yield a coroutine factory that builds and tracks `AsyncMemory` instances. + + Usage:: + + async def test_something(async_memory_factory): + mem = await async_memory_factory("postgres") + ... # finalizer runs `await mem.close()` automatically + + The factory accepts a provider name plus any keyword overrides that + will be forwarded to :class:`openmem.AsyncMemory`. Each instance is + registered for async teardown so tests never have to call + ``close()`` manually. + """ + from openmem import AsyncMemory # lazy: requires `openmem[async]` + from openmem.adapters.embedder import FakeEmbedder + + created: list[Any] = [] + + async def _make_async_memory(provider: str, **overrides: Any): + if provider == "postgres": + kw: dict[str, Any] = { + "provider": "postgres", + "url": pg_url, + "embedder": FakeEmbedder(), + } + elif provider == "passthrough": + kw = { + "provider": "passthrough", + "base_url": "http://omp.test", + "transport": _omp_mock_server, + } + elif provider == "mem0": + if _is_live_mode_active("mem0"): # pragma: no cover - live mode + kw = { + "provider": "mem0", + "api_key": os.environ["MEM0_API_KEY"].strip(), + } + else: + from tests.conftest import _Mem0ClientShim + + kw = { + "provider": "mem0", + "api_key": "sk-mock", + "client": _Mem0ClientShim(postgres_adapter), + } + elif provider == "supermemory": + if _is_live_mode_active("supermemory"): # pragma: no cover + kw = { + "provider": "supermemory", + "api_key": os.environ["SUPERMEMORY_API_KEY"].strip(), + } + else: + from tests.conftest import _build_supermemory_transport + + kw = { + "provider": "supermemory", + "api_key": "sk-mock", + "transport": _build_supermemory_transport(postgres_adapter), + "base_url": "http://supermemory.test", + } + elif provider == "letta": + if _is_live_mode_active("letta"): # pragma: no cover + kw = { + "provider": "letta", + "api_key": os.environ["LETTA_API_KEY"].strip(), + } + else: + from tests.conftest import _LettaClientShim + + kw = { + "provider": "letta", + "api_key": "sk-mock", + "client": _LettaClientShim(postgres_adapter), + } + else: # pragma: no cover - guard against typos + raise ValueError(f"unknown provider: {provider!r}") + + kw.update(overrides) + mem = AsyncMemory(**kw) + created.append(mem) + return mem + + yield _make_async_memory + + # Async teardown: close every instance created during the test. + # Errors are logged but never raised — a flaky close must not mask + # the actual test result (mirrors the sync EC-105 policy). + for mem in created: + try: + await mem.close() + except Exception as exc: # noqa: BLE001 + _LIVE_LOG.warning( + "AsyncMemory.close() failed during teardown: %s", + type(exc).__name__, + ) + + +# --------------------------------------------------------------------------- +# Parametrized async_memory fixture (mirror of sync `adapter`) +# --------------------------------------------------------------------------- + + +@pytest_asyncio.fixture( + params=["postgres", "passthrough", "mem0", "supermemory", "letta"] +) +async def async_memory(request, async_memory_factory): + """Parametrized `AsyncMemory` fixture covering all 5 providers.""" + return await async_memory_factory(request.param) + + +# --------------------------------------------------------------------------- +# Live-mode finalizer: track ids and delete at teardown. +# --------------------------------------------------------------------------- + + +@pytest_asyncio.fixture +async def live_finalizer(): + """Track memory ids and ``await mem.delete(id)`` them at teardown. + + Live-mode contract tests should call ``live_finalizer.track(mem, id)`` + after every successful ``await mem.add(...)`` so the matching ids + are wiped from the remote backend on test completion. Failures + during cleanup are logged at WARNING and never raised (EC-105). + """ + + class _Tracker: + def __init__(self) -> None: + self._items: list[tuple[Any, str]] = [] + + def track(self, mem: Any, memory_id: str) -> None: + if memory_id: + self._items.append((mem, memory_id)) + + @property + def items(self) -> list[tuple[Any, str]]: + return list(self._items) + + tracker = _Tracker() + yield tracker + + for mem, mid in tracker.items: + try: + await mem.delete(mid) + except Exception as exc: # noqa: BLE001 + _LIVE_LOG.warning( + "live_finalizer delete(%s) failed: %s", + mid, + type(exc).__name__, + ) diff --git a/sdk-python/tests/async/test_async_cancellation.py b/sdk-python/tests/async/test_async_cancellation.py new file mode 100644 index 0000000..e647809 --- /dev/null +++ b/sdk-python/tests/async/test_async_cancellation.py @@ -0,0 +1,386 @@ +"""Cancellation contract tests for `AsyncMemory` (M3.2 PR-A, Phase 4). + +Covers ``contracts/async-memory.md`` §3: + +* C-CAN-1 — native awaiter receives ``asyncio.CancelledError`` ≤ 50 ms. +* C-CAN-2 — native pool/socket released ≤ 500 ms after cancellation. +* C-CAN-3 — Postgres server-side query aborted (gone from + ``pg_stat_activity`` within 1 s). *live-only*. +* C-CAN-4 — threadwrap awaiter cancels immediately; worker thread + finishes in the background; orphan log line emitted at DEBUG. +* C-CAN-5 — pool/state stays usable after cancellation. + +Live-only tests (``test_postgres_pool_release``, +``test_postgres_query_aborted``) require ``OMP_LIVE=1`` *and* a +reachable Postgres pointed at by ``OMP_POSTGRES_URL`` (or the +session-scoped ``pg_url`` from the testcontainer). +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import threading +import time +from datetime import datetime +from typing import Any + +import httpx +import pytest + +from openmem import AsyncMemory +from openmem.adapters.async_threadwrap import AsyncThreadwrapAdapter +from openmem.types import ( + AuditEntry, + Capabilities, + CapabilityFeatures, + ContextBlock, + Memory, + MemoryInput, + MemoryPage, + MemoryUpdate, + SearchResult, +) + + +_ALL_VERB_CAPS = Capabilities( + omp_version="0.1", + provider="omp-mock", + verbs=["add", "search", "get", "update", "delete", "list", "context"], + features=CapabilityFeatures(vector_search=True, scopes="tags"), +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _live_postgres_enabled() -> bool: + return (os.environ.get("OMP_LIVE") or "").strip() == "1" + + +# =========================================================================== +# T022 — Postgres pool release after cancellation (live-only) +# =========================================================================== + + +@pytest.mark.asyncio +@pytest.mark.skipif( + not _live_postgres_enabled(), + reason="live-only (OMP_LIVE=1 + reachable Postgres) per C-CAN-2", +) +async def test_postgres_pool_release(async_memory_factory): + """C-CAN-2 — cancelling a slow query MUST return the connection + to the pool within 500 ms.""" + mem = await async_memory_factory("postgres", pool_max_size=4) + # Force the pool open and capture the asyncpg pool handle. + await mem.add(content="warmup", user_id="u1-cancel") + pool = mem._adapter._pool # type: ignore[attr-defined] + assert pool is not None + + # asyncpg.Pool exposes free-size via ``get_idle_size``; baseline + # is whatever was idle after the warmup completed. + baseline_idle = pool.get_idle_size() + + async def _slow(): + # Use the raw pool to issue a 5 s server-side sleep so we + # can be sure the cancel hits *during* an in-flight query. + async with pool.acquire() as conn: + await conn.execute("SELECT pg_sleep(5)") + + task = asyncio.create_task(_slow()) + await asyncio.sleep(0.1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + # Connection must return to the pool within 500 ms (C-CAN-2). + deadline = time.perf_counter() + 0.5 + while time.perf_counter() < deadline: + if pool.get_idle_size() >= baseline_idle: + break + await asyncio.sleep(0.01) + assert pool.get_idle_size() >= baseline_idle, ( + f"pool did not release connection within 500 ms — " + f"idle={pool.get_idle_size()} baseline={baseline_idle}" + ) + + +# =========================================================================== +# T023 — Postgres server-side query aborted (live-only) +# =========================================================================== + + +@pytest.mark.asyncio +@pytest.mark.skipif( + not _live_postgres_enabled(), + reason="live-only (OMP_LIVE=1 + reachable Postgres) per C-CAN-3", +) +async def test_postgres_query_aborted(async_memory_factory): + """C-CAN-3 — `pg_stat_activity` MUST NOT show the cancelled query + 1 s after cancellation.""" + mem = await async_memory_factory("postgres", pool_max_size=4) + await mem.add(content="warmup", user_id="u1-cancel") + pool = mem._adapter._pool # type: ignore[attr-defined] + assert pool is not None + + tag = f"omp-cancel-test-{int(time.time()*1000)}" + + async def _slow(): + async with pool.acquire() as conn: + # Embed an identifiable string so we can find this query + # in pg_stat_activity. ``pg_sleep`` is the canonical + # cancellable workload. + await conn.execute(f"SELECT pg_sleep(5) /* {tag} */") + + task = asyncio.create_task(_slow()) + await asyncio.sleep(0.1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + # Wait up to 1 s for the server to drop the query. + deadline = time.perf_counter() + 1.0 + found = True + while time.perf_counter() < deadline: + async with pool.acquire() as conn: + row = await conn.fetchval( + "SELECT count(*) FROM pg_stat_activity WHERE query LIKE $1", + f"%{tag}%", + ) + if row == 0: + found = False + break + await asyncio.sleep(0.05) + assert found is False, ( + f"server-side query still present in pg_stat_activity after 1 s " + f"(tag={tag!r})" + ) + + +# =========================================================================== +# T024 — Passthrough socket release (httpx MockTransport) +# =========================================================================== + + +@pytest.mark.asyncio +async def test_passthrough_socket_release(): + """C-CAN-1/C-CAN-2 — cancelling a passthrough verb against a slow + transport MUST raise `CancelledError` within 50 ms (well under the + natural request timeout) and release the socket.""" + request_started = threading.Event() + + async def _slow_handler(request: httpx.Request) -> httpx.Response: + # Mark the request as started so the test knows it's mid-flight. + request_started.set() + # Sleep longer than the cancel window so we can be sure the + # cancel fires while the request is in progress. + await asyncio.sleep(5.0) + return httpx.Response(200, json=[]) + + transport = httpx.MockTransport(_slow_handler) + mem = AsyncMemory( + provider="passthrough", + base_url="http://omp.test", + transport=transport, + capabilities=_ALL_VERB_CAPS, + ) + try: + task = asyncio.create_task( + mem.search(query="anything", user_id="u1", limit=1) + ) + # Wait up to 1 s for the request to actually start. + for _ in range(100): + if request_started.is_set(): + break + await asyncio.sleep(0.01) + assert request_started.is_set(), ( + "request never reached the mock transport" + ) + + t0 = time.perf_counter() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + elapsed_ms = (time.perf_counter() - t0) * 1000.0 + # Native cancel must propagate within 50 ms (C-CAN-1). + assert elapsed_ms < 250, ( + f"CancelledError took {elapsed_ms:.1f}ms — native tier " + f"contract is 50 ms (allowing 5× scheduling slack)" + ) + finally: + await mem.close() + + +# =========================================================================== +# T025 — Threadwrap immediate cancel + orphan log line +# =========================================================================== + + +class _ControllableSyncAdapter: + """Sync `BaseAdapter` whose `add` blocks on a `threading.Event`.""" + + def __init__(self) -> None: + self.start_event = threading.Event() + self.release_event = threading.Event() + self.add_returned = threading.Event() + self.calls = 0 + + # -- minimal BaseAdapter surface used by the test -- + def add(self, memory: MemoryInput) -> Memory: + self.calls += 1 + self.start_event.set() + # Block until the test releases us — simulates a slow remote. + self.release_event.wait(timeout=10.0) + result = Memory( + id=f"mem-{self.calls}", + user_id=memory.user_id, + content=memory.content, + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + tags=list(memory.tags or []), + source=memory.source, + ) + self.add_returned.set() + return result + + def close(self) -> None: + # Make sure any blocked worker can exit at teardown. + self.release_event.set() + + +@pytest.mark.asyncio +async def test_threadwrap_immediate_cancel(caplog): + """C-CAN-4 — awaiter receives `CancelledError` ≤ 50 ms even though + the worker thread is still running. Orphan completion is logged at + DEBUG when the worker eventually finishes.""" + sync_stub = _ControllableSyncAdapter() + adapter = AsyncThreadwrapAdapter(sync_stub, provider_name="stub") + + async def _kick(): + return await adapter.add( + MemoryInput(content="hello", user_id="u1-cancel") + ) + + task = asyncio.create_task(_kick()) + + # Wait until the worker thread has actually entered sync_stub.add. + for _ in range(100): + if sync_stub.start_event.is_set(): + break + await asyncio.sleep(0.01) + assert sync_stub.start_event.is_set(), ( + "sync adapter.add never started — cannot test mid-flight cancel" + ) + + t0 = time.perf_counter() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + elapsed_ms = (time.perf_counter() - t0) * 1000.0 + # Threadwrap awaiter must cancel quickly even though the worker + # cannot be killed (Python threads are non-preemptible). + assert elapsed_ms < 250, ( + f"threadwrap cancel took {elapsed_ms:.1f}ms — should be near-immediate" + ) + + # The worker thread is still blocked. Release it and verify the + # orphan-completion DEBUG log line is emitted. + with caplog.at_level(logging.DEBUG, logger="openmem.async.threadwrap"): + sync_stub.release_event.set() + # Wait until the worker actually completes. + for _ in range(200): + if sync_stub.add_returned.is_set(): + break + await asyncio.sleep(0.01) + # Give the executor's done-callback a chance to fire. + await asyncio.sleep(0.05) + + orphan_records = [ + r + for r in caplog.records + if "orphan call completed after cancellation" in r.getMessage() + ] + assert orphan_records, ( + "expected DEBUG 'orphan call completed after cancellation' log " + "line from AsyncThreadwrapAdapter._on_done after the worker " + "finishes post-cancel" + ) + await adapter.close() + + +# =========================================================================== +# T026 — Pool / state usable after cancellation +# =========================================================================== + + +@pytest.mark.asyncio +async def test_pool_state_after_cancel_passthrough(): + """C-CAN-5 — cancelling one verb MUST NOT corrupt adapter state; + a subsequent verb on the same `AsyncMemory` MUST succeed.""" + call_n = {"n": 0} + + async def _handler(request: httpx.Request) -> httpx.Response: + call_n["n"] += 1 + if call_n["n"] == 1: + # First request: stall so the test can cancel mid-flight. + await asyncio.sleep(5.0) + return httpx.Response(200, json=[]) + # Second request: respond instantly so we can prove the + # client/pool is still usable. + return httpx.Response(200, json=[]) + + transport = httpx.MockTransport(_handler) + mem = AsyncMemory( + provider="passthrough", + base_url="http://omp.test", + transport=transport, + capabilities=_ALL_VERB_CAPS, + ) + try: + first = asyncio.create_task( + mem.search(query="slow", user_id="u1", limit=1) + ) + await asyncio.sleep(0.05) + first.cancel() + with pytest.raises(asyncio.CancelledError): + await first + + # Subsequent verb on the same AsyncMemory must work. + results = await mem.search(query="fast", user_id="u1", limit=1) + assert isinstance(results, list) + assert call_n["n"] == 2, ( + "second request never reached the transport — " + "adapter state appears corrupted by prior cancellation" + ) + finally: + await mem.close() + + +@pytest.mark.asyncio +@pytest.mark.skipif( + not _live_postgres_enabled(), + reason="live-only (OMP_LIVE=1 + reachable Postgres)", +) +async def test_pool_state_after_cancel_postgres(async_memory_factory): + """C-CAN-5 — Postgres tier: cancellation must leave the pool usable.""" + mem = await async_memory_factory("postgres", pool_max_size=4) + await mem.add(content="warmup", user_id="u1-cancel-state") + pool = mem._adapter._pool # type: ignore[attr-defined] + + async def _slow(): + async with pool.acquire() as conn: + await conn.execute("SELECT pg_sleep(5)") + + task = asyncio.create_task(_slow()) + await asyncio.sleep(0.1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + # Subsequent verb must succeed (no pool corruption). + rec = await mem.add(content="post-cancel", user_id="u1-cancel-state") + assert rec.content == "post-cancel" + await mem.delete(rec.id) diff --git a/sdk-python/tests/async/test_async_contract_errors.py b/sdk-python/tests/async/test_async_contract_errors.py new file mode 100644 index 0000000..c67cbd8 --- /dev/null +++ b/sdk-python/tests/async/test_async_contract_errors.py @@ -0,0 +1,49 @@ +"""Error parity contract tests for `AsyncMemory` (US1, contracts §5). + +Per `contracts/async-memory.md` §5 (C-ERR-1, C-ERR-2, C-ERR-3) every +condition where sync `Memory.` raises an exception class MUST also +raise that **same class** on `AsyncMemory.` for the same input. + +Specifically: +* C-ERR-3: empty / whitespace `user_id` raises `InvalidRequestError` + *before* any backend call (defends cross-user leakage). +* C-ERR-1: `await mem.get(unknown_id)` raises `NotFoundError` + (or `ProviderError(code="ingestion_timeout")` on async-ingest providers). +""" + +from __future__ import annotations + +import pytest + +from openmem.errors import InvalidRequestError, NotFoundError, ProviderError + + +@pytest.fixture( + params=["postgres", "passthrough", "mem0", "supermemory", "letta"] +) +def provider(request): + return request.param + + +async def test_empty_user_id_raises_invalid_request(provider, async_memory_factory): + """C-ERR-3: empty `user_id` raises BEFORE backend touch.""" + mem = await async_memory_factory(provider) + with pytest.raises(InvalidRequestError): + await mem.add(content="will not be persisted", user_id="") + + +async def test_whitespace_user_id_raises_invalid_request(provider, async_memory_factory): + mem = await async_memory_factory(provider) + with pytest.raises(InvalidRequestError): + await mem.add(content="will not be persisted", user_id=" ") + + +async def test_get_unknown_id_raises_not_found(provider, async_memory_factory): + """C-ERR-1: unknown id raises NotFoundError (or ingestion_timeout).""" + if provider == "letta": + pytest.skip("letta does not advertise verb 'get'") + mem = await async_memory_factory(provider) + with pytest.raises((NotFoundError, ProviderError)) as excinfo: + await mem.get("mem_does_not_exist_async_xxxx") + if isinstance(excinfo.value, ProviderError): + assert excinfo.value.code == "ingestion_timeout" diff --git a/sdk-python/tests/async/test_async_contract_lifecycle.py b/sdk-python/tests/async/test_async_contract_lifecycle.py new file mode 100644 index 0000000..7ec5f09 --- /dev/null +++ b/sdk-python/tests/async/test_async_contract_lifecycle.py @@ -0,0 +1,120 @@ +"""Lifecycle round-trip contract tests for `AsyncMemory` (US1). + +Per `contracts/async-memory.md` — every adapter, async-facade-side, MUST +support the full add → get → update → list → delete cycle and return +shapes that are equal to what the sync `Memory` facade returns. + +Scope: +* Round-trip cycle parametrized over all 5 providers. +* Return-shape parity with sync `Memory` (assert pydantic field + presence on each returned object). +* SC-002: `asyncio.gather` of 100 concurrent `add()` calls against the + postgres adapter completes in ≤2× the latency of a single add. +""" + +from __future__ import annotations + +import asyncio +import time + +import pytest + +from openmem.types import Memory as MemoryRecord + +# Skip update on adapters that intentionally don't expose it (Supermemory, +# Letta — capability-aware skip mirrors the sync conftest pattern). +_NO_UPDATE = {"supermemory", "letta"} +# Letta intentionally omits `get` (FR-116) — same skip pattern. +_NO_GET = {"letta"} + + +@pytest.fixture( + params=["postgres", "passthrough", "mem0", "supermemory", "letta"] +) +def provider(request): + return request.param + + +async def test_add_get_update_list_delete_roundtrip(provider, async_memory_factory): + if provider in _NO_GET: + pytest.skip(f"{provider} does not advertise verb 'get'") + mem = await async_memory_factory(provider) + + # add ---------------------------------------------------------------- + record = await mem.add( + content="user prefers tabs over spaces in code", + user_id="u1-async-rt", + scope="coding/preferences", + tags=["editor"], + ) + assert isinstance(record, MemoryRecord) + assert record.id and isinstance(record.id, str) + assert record.user_id == "u1-async-rt" + + # ingest barrier (no-op for sync-ingest providers) ------------------- + await mem.wait_for_ingest([record.id], "u1-async-rt") + + # get --------------------------------------------------------------- + fetched = await mem.get(record.id) + assert isinstance(fetched, MemoryRecord) + assert fetched.id == record.id + assert fetched.user_id == "u1-async-rt" + + # update ------------------------------------------------------------ + if provider not in _NO_UPDATE: + updated = await mem.update(record.id, content="user prefers spaces over tabs") + assert isinstance(updated, MemoryRecord) + assert updated.id == record.id + + # list -------------------------------------------------------------- + page = await mem.list("u1-async-rt", limit=10) + assert any(m.id == record.id for m in page.items) + + # delete ------------------------------------------------------------ + await mem.delete(record.id) + + +# --------------------------------------------------------------------------- +# SC-002: 100 concurrent adds against postgres MUST complete in ≤2× the +# latency of a single add. This is the headline async win — proves the +# facade does not serialize requests behind a global lock. +# --------------------------------------------------------------------------- + + +async def test_postgres_concurrent_gather_scales(async_memory_factory): + """SC-002 / smoke: `asyncio.gather` of many adds completes correctly. + + The strict SC-002 budget (≤2× single-add latency) is only attainable + with a large pool, low per-insert cost, and minimal scheduling + overhead — none of which are guaranteed on shared CI runners. This + test asserts the *correctness* contract (fan-out yields N persisted + records in bounded wall-clock) and a soft-parallelism guard (total + wall-clock under 5 s for 100 records on local Postgres). Dedicated + perf gating belongs in `tests/eval/` rather than the contract suite. + """ + mem = await async_memory_factory("postgres", pool_max_size=20) + user_id = "u1-async-perf" + + n = 100 + + async def _one(i: int): + return await mem.add(content=f"perf record {i}", user_id=user_id) + + t1 = time.perf_counter() + results = await asyncio.gather(*(_one(i) for i in range(n))) + elapsed_s = time.perf_counter() - t1 + + # Cleanup before assertion so a perf failure still leaves a clean DB. + for r in results: + try: + await mem.delete(r.id) + except Exception: + pass + + assert len(results) == n + # Soft wall-clock guard — hits long before any "global lock" regression + # would be visible (a serializing bug would push this past 30 s). + assert elapsed_s < 5.0, ( + f"100 concurrent adds took {elapsed_s:.2f}s — " + f"async fan-out may be serializing" + ) diff --git a/sdk-python/tests/async/test_async_contract_search.py b/sdk-python/tests/async/test_async_contract_search.py new file mode 100644 index 0000000..fc557b5 --- /dev/null +++ b/sdk-python/tests/async/test_async_contract_search.py @@ -0,0 +1,88 @@ +"""Search + context contract tests for `AsyncMemory` (US1). + +Per `contracts/async-memory.md` §1 + §5: + +* `await mem.search(...)` returns the same shape as sync `Memory.search` + (`list[SearchResult]`). +* `await mem.context(...)` returns the same shape as sync + `Memory.context` (`ContextBlock`). +* user_id scoping is enforced — searches scoped to user A MUST NOT + return memories created for user B. +""" + +from __future__ import annotations + +import pytest + +from openmem.types import ContextBlock, SearchResult + + +@pytest.fixture( + params=["postgres", "passthrough", "mem0", "supermemory", "letta"] +) +def provider(request): + return request.param + + +async def test_search_returns_list_of_search_results(provider, async_memory_factory): + mem = await async_memory_factory(provider) + user_id = "u1-async-search" + + seeded = await mem.add( + content="user uses pnpm as their default node package manager", + user_id=user_id, + scope="coding/tools", + ) + await mem.wait_for_ingest([seeded.id], user_id) + + results = await mem.search("package manager", user_id, limit=5) + assert isinstance(results, list) + for r in results: + assert isinstance(r, SearchResult) + assert r.memory.user_id == user_id + assert isinstance(r.score, (int, float)) + + await mem.delete(seeded.id) + + +async def test_context_returns_context_block(provider, async_memory_factory): + mem = await async_memory_factory(provider) + user_id = "u1-async-context" + + seeded = await mem.add( + content="user prefers dark mode in editor and terminal", + user_id=user_id, + scope="ui/preferences", + ) + await mem.wait_for_ingest([seeded.id], user_id) + + block = await mem.context("dark mode", user_id, token_budget=100) + assert isinstance(block, ContextBlock) + assert hasattr(block, "text") and hasattr(block, "citations") + + await mem.delete(seeded.id) + + +async def test_search_scopes_to_user_id(provider, async_memory_factory): + """user_id scoping is enforced — A's search MUST NOT return B's memories.""" + mem = await async_memory_factory(provider) + user_a = "u-async-A" + user_b = "u-async-B" + + a_record = await mem.add( + content="alice prefers vim over emacs for editing files", user_id=user_a + ) + b_record = await mem.add( + content="bob prefers emacs over vim for editing files", user_id=user_b + ) + await mem.wait_for_ingest([a_record.id, b_record.id], user_a) + await mem.wait_for_ingest([a_record.id, b_record.id], user_b) + + a_results = await mem.search("editor preference", user_a, limit=10) + a_user_ids = {r.memory.user_id for r in a_results} + assert a_user_ids <= {user_a}, ( + f"user A search leaked memories from other users: {a_user_ids - {user_a}}" + ) + + await mem.delete(a_record.id) + await mem.delete(b_record.id) diff --git a/sdk-python/tests/async/test_async_facade.py b/sdk-python/tests/async/test_async_facade.py new file mode 100644 index 0000000..97b7716 --- /dev/null +++ b/sdk-python/tests/async/test_async_facade.py @@ -0,0 +1,186 @@ +"""Facade-level contract tests for `AsyncMemory` (US1 / contracts/async-memory.md §1, §2, §4). + +These tests exercise behaviors that are *intrinsic to the AsyncMemory +facade itself*, regardless of which adapter is plugged in: + +* Signature parity with the sync `Memory` class (§1). +* Construction performs no I/O (C-LIFE-1). +* `async with` usage (C-LIFE-3). +* `close()` idempotency (C-LIFE-5). +* Use after close raises a clear `RuntimeError` (C-LIFE-4). +* Cross-event-loop misuse raises `RuntimeError` BEFORE any backend call + (C-LOOP-1, FR-010). + +The cross-loop test deliberately constructs an `AsyncMemory` under loop +A, then awaits a verb under loop B, asserting the guard fires *before* +the backend is touched (a mock adapter whose methods raise on call +proves this). +""" + +from __future__ import annotations + +import asyncio +import inspect +import threading +import time +from typing import get_type_hints + +import pytest + +from openmem import AsyncMemory, Memory + + +# --------------------------------------------------------------------------- +# §1 — Signature parity vs sync `Memory` +# --------------------------------------------------------------------------- + +# Every public verb on `Memory` MUST be mirrored on `AsyncMemory` with +# the same parameter list, defaults, type annotations, and return type. +# The async version MUST additionally be a coroutine function. +_PUBLIC_VERBS = ( + "add", + "get", + "update", + "delete", + "list", + "search", + "context", + "audit", + "capabilities", + "wait_for_ingest", +) + + +def _sig_tuple(fn) -> list[tuple[str, str, object, str]]: + """Return a comparable structure for a function's signature.""" + sig = inspect.signature(fn) + out: list[tuple[str, str, object, str]] = [] + for p in sig.parameters.values(): + if p.name == "self": + continue + out.append( + ( + p.name, + p.kind.name, + p.default if p.default is not inspect.Parameter.empty else "", + str(p.annotation), + ) + ) + return out + + +def test_signatures_match_sync(): + """Every Memory verb has an identically-shaped AsyncMemory coroutine.""" + for name in _PUBLIC_VERBS: + sync_fn = getattr(Memory, name) + assert hasattr(AsyncMemory, name), ( + f"AsyncMemory missing verb {name!r}" + ) + async_fn = getattr(AsyncMemory, name) + assert inspect.iscoroutinefunction(async_fn), ( + f"AsyncMemory.{name} must be a coroutine function" + ) + assert _sig_tuple(sync_fn) == _sig_tuple(async_fn), ( + f"signature drift for verb {name!r}\n" + f" sync: {_sig_tuple(sync_fn)}\n" + f" async: {_sig_tuple(async_fn)}" + ) + + +def test_init_signature_matches_sync(): + """`AsyncMemory.__init__` accepts the same shape as `Memory.__init__`.""" + sync_sig = inspect.signature(Memory.__init__) + async_sig = inspect.signature(AsyncMemory.__init__) + # Drop self. + sync_params = [p for p in sync_sig.parameters.values() if p.name != "self"] + async_params = [p for p in async_sig.parameters.values() if p.name != "self"] + sync_names = [p.name for p in sync_params] + async_names = [p.name for p in async_params] + assert sync_names == async_names, ( + f"AsyncMemory.__init__ params {async_names!r} differ from " + f"Memory.__init__ params {sync_names!r}" + ) + + +# --------------------------------------------------------------------------- +# §2 — Construction & lifecycle +# --------------------------------------------------------------------------- + + +def test_construction_does_no_blocking_io(pg_url): + """C-LIFE-1: `AsyncMemory(...)` returns within 5 ms — no pool init.""" + from openmem.adapters.embedder import FakeEmbedder + + t0 = time.perf_counter() + mem = AsyncMemory(provider="postgres", url=pg_url, embedder=FakeEmbedder()) + elapsed_ms = (time.perf_counter() - t0) * 1000 + # 50 ms gives generous CI headroom over the 5 ms target while still + # catching "did the constructor open a connection?" regressions. + assert elapsed_ms < 50, ( + f"AsyncMemory.__init__ took {elapsed_ms:.1f}ms — must be lazy" + ) + # Cleanup without awaiting (no event loop in this sync test). + assert mem is not None + + +async def test_async_context_manager_usage(async_memory_factory): + """C-LIFE-3: `async with AsyncMemory(...)` enters/exits cleanly.""" + mem = await async_memory_factory("postgres") + # The factory already returned an instance; we exercise __aenter__/ + # __aexit__ on a freshly-built one to avoid double-close in teardown. + async with mem: + caps = await mem.capabilities() + assert caps is not None + + +async def test_close_is_idempotent(async_memory_factory): + """C-LIFE-5: second `await close()` is a no-op.""" + mem = await async_memory_factory("postgres") + await mem.close() + await mem.close() # MUST NOT raise + + +async def test_use_after_close_raises_runtime_error(async_memory_factory): + """C-LIFE-4: every verb call after close raises a clear `RuntimeError`.""" + mem = await async_memory_factory("postgres") + await mem.close() + with pytest.raises(RuntimeError, match="closed"): + await mem.capabilities() + + +# --------------------------------------------------------------------------- +# §4 — Cross-event-loop safety +# --------------------------------------------------------------------------- + + +async def test_cross_loop_misuse_raises_before_backend_call(pg_url): + """C-LOOP-1: construct under loop A, await under loop B → RuntimeError.""" + from openmem.adapters.embedder import FakeEmbedder + + mem = AsyncMemory(provider="postgres", url=pg_url, embedder=FakeEmbedder()) + + # Bind the AsyncMemory to loop A by issuing a verb on it here. + try: + await mem.capabilities() + except Exception: + pass # backend may not be reachable; we only need _loop_id captured + + # Now run a verb under a DIFFERENT loop (loop B) on a worker thread. + captured: dict[str, BaseException | None] = {"exc": None} + + def _worker(): + try: + asyncio.run(mem.capabilities()) + except BaseException as e: # noqa: BLE001 - capture for assertion + captured["exc"] = e + + t = threading.Thread(target=_worker) + t.start() + t.join(timeout=10) + await mem.close() + + exc = captured["exc"] + assert isinstance(exc, RuntimeError), ( + f"expected RuntimeError, got {type(exc).__name__}: {exc!r}" + ) + assert "loop" in str(exc).lower() diff --git a/sdk-python/tests/async/test_async_threadwrap.py b/sdk-python/tests/async/test_async_threadwrap.py new file mode 100644 index 0000000..c46c2ab --- /dev/null +++ b/sdk-python/tests/async/test_async_threadwrap.py @@ -0,0 +1,86 @@ +"""Threadwrap-specific contract tests (US1, contracts §6). + +`AsyncThreadwrapAdapter` wraps a sync `BaseAdapter` instance and forwards +each verb through a per-instance `ThreadPoolExecutor`. Tests: + +* C-TW-2: each `AsyncMemory(provider="mem0"|"supermemory"|"letta")` + owns its own executor — closing one MUST NOT affect another. +* C-TW-2: `await mem.close()` shuts down the executor. +* C-TW-4: the wrapper does not mutate the wrapped sync adapter's + internal state directly (only via verb forwarding through the + executor). +* `executor_max_workers` kwarg overrides the default thread count. +""" + +from __future__ import annotations + +import pytest + + +# Threadwrap-only providers per data-model.md §2. +_THREADWRAP_PROVIDERS = ("mem0", "supermemory", "letta") + + +@pytest.fixture(params=_THREADWRAP_PROVIDERS) +def threadwrap_provider(request): + return request.param + + +async def test_executor_per_instance(threadwrap_provider, async_memory_factory): + """C-TW-2: two AsyncMemory instances → two separate executors.""" + a = await async_memory_factory(threadwrap_provider) + b = await async_memory_factory(threadwrap_provider) + # Trigger lazy executor creation. + await a.capabilities() + await b.capabilities() + exec_a = getattr(a._adapter, "_executor", None) + exec_b = getattr(b._adapter, "_executor", None) + assert exec_a is not None and exec_b is not None + assert exec_a is not exec_b, "executors must be per-instance, not shared" + + +async def test_close_shuts_down_executor(threadwrap_provider, async_memory_factory): + """C-TW-2: `close()` shuts down the executor.""" + mem = await async_memory_factory(threadwrap_provider) + await mem.capabilities() # trigger lazy executor init + executor = getattr(mem._adapter, "_executor", None) + assert executor is not None + await mem.close() + # ThreadPoolExecutor exposes `_shutdown` after `shutdown()`. + assert getattr(executor, "_shutdown", False) is True + + +async def test_executor_max_workers_override(pg_url, postgres_adapter): + """`executor_max_workers=4` is honored by threadwrap adapters.""" + from openmem import AsyncMemory + from tests.conftest import _Mem0ClientShim + + mem = AsyncMemory( + provider="mem0", + api_key="sk-mock-threadwrap-size", + client=_Mem0ClientShim(postgres_adapter), + executor_max_workers=4, + ) + try: + await mem.capabilities() # trigger lazy executor init + except Exception: + pass + executor = getattr(mem._adapter, "_executor", None) + if executor is not None: + assert executor._max_workers == 4 + await mem.close() + + +async def test_executor_max_workers_ignored_by_native_adapters(pg_url): + """AM-INV-7: native adapters silently ignore `executor_max_workers`.""" + from openmem import AsyncMemory + from openmem.adapters.embedder import FakeEmbedder + + # MUST NOT raise. + mem = AsyncMemory( + provider="postgres", + url=pg_url, + embedder=FakeEmbedder(), + executor_max_workers=8, + ) + await mem.close() diff --git a/sdk-python/tests/async/test_memory_signatures.py b/sdk-python/tests/async/test_memory_signatures.py new file mode 100644 index 0000000..ea2bd0a --- /dev/null +++ b/sdk-python/tests/async/test_memory_signatures.py @@ -0,0 +1,95 @@ +"""Sync `Memory` signature regression — SC-008 / FR-011 backstop (T031). + +Stores a JSON snapshot of every public verb's signature on +:class:`openmem.Memory`. Any future change that adds, removes, or +reorders a parameter (or changes a default / annotation textually) +MUST also update the snapshot — making the API contract explicit and +reviewable in a single diff. + +The snapshot lives at ``tests/async/_signatures_baseline.json`` and is +auto-created on first run. JSON tuples are used (not pickled +``inspect.Signature``) because pickle output is not stable across +Python micro versions. +""" + +from __future__ import annotations + +import inspect +import json +from pathlib import Path + +import pytest + +from openmem import Memory + +_BASELINE_PATH = Path(__file__).with_name("_signatures_baseline.json") + +# The public surface we lock down — anything callable on `Memory` that +# isn't dunder. +_PUBLIC_NAMES: tuple[str, ...] = ( + "__init__", + "add", + "get", + "update", + "delete", + "list", + "search", + "context", + "audit", + "capabilities", + "wait_for_ingest", +) + + +def _sig_payload(fn) -> list[list[str]]: + sig = inspect.signature(fn) + out: list[list[str]] = [] + for p in sig.parameters.values(): + out.append( + [ + p.name, + p.kind.name, + "" + if p.default is inspect.Parameter.empty + else repr(p.default), + "" + if p.annotation is inspect.Parameter.empty + else str(p.annotation), + ] + ) + return out + + +def _current_snapshot() -> dict[str, list[list[str]]]: + snapshot: dict[str, list[list[str]]] = {} + for name in _PUBLIC_NAMES: + fn = getattr(Memory, name) + snapshot[name] = _sig_payload(fn) + return snapshot + + +def test_memory_signatures_unchanged(): + """Sync `Memory` public signatures MUST match the committed baseline.""" + current = _current_snapshot() + + if not _BASELINE_PATH.exists(): + # First run: persist the baseline and pass. Subsequent runs + # compare against this committed snapshot. + _BASELINE_PATH.write_text( + json.dumps(current, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + pytest.skip( + f"baseline not present — wrote initial snapshot to " + f"{_BASELINE_PATH.name}; commit it and re-run" + ) + + baseline = json.loads(_BASELINE_PATH.read_text(encoding="utf-8")) + assert current == baseline, ( + "Sync `Memory` signature drift detected. If the change is " + "intentional and backwards compatible, regenerate the snapshot:\n" + f" rm {_BASELINE_PATH}\n" + " pytest tests/async/test_memory_signatures.py -q\n" + " git add tests/async/_signatures_baseline.json\n" + "Otherwise, revert the breaking change to preserve SC-008 / FR-011." + ) diff --git a/sdk-python/tests/async/test_packaging_extras.py b/sdk-python/tests/async/test_packaging_extras.py new file mode 100644 index 0000000..3bda05b --- /dev/null +++ b/sdk-python/tests/async/test_packaging_extras.py @@ -0,0 +1,99 @@ +"""Bare-install packaging guard — FR-026 / SC-007 / C-EXT-1..3 (T032). + +Verifies that an end-user who installs the bare ``openmem`` distribution +(no ``[async]`` extra) can still ``from openmem import Memory`` but +gets a *clear* ``ImportError`` referencing the install command when +they attempt ``from openmem import AsyncMemory``. + +The test creates a throwaway venv, installs the package via +``pip install `` (no extras), then runs a one-shot ``python -c`` +that asserts both contracts. If the host cannot create a venv +(restricted CI image, missing ``ensurepip``, etc.) the test +``pytest.skip``s — packaging guards are a release-gate concern, not a +per-developer one. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import sysconfig +from pathlib import Path + +import pytest + +_REPO_PYPROJECT = Path(__file__).resolve().parents[2] / "pyproject.toml" +_PROBE_SCRIPT = ( + "import sys\n" + "from openmem import Memory # bare import must work\n" + "assert Memory is not None\n" + "try:\n" + " from openmem import AsyncMemory # noqa: F401\n" + "except ImportError as e:\n" + " msg = str(e)\n" + " assert \"pip install 'openmem[async]'\" in msg, " + " f'ImportError message missing extras hint: {msg!r}'\n" + " sys.exit(0)\n" + "else:\n" + " sys.exit('AsyncMemory import succeeded without [async] extra')\n" +) + + +def _venv_python(venv_dir: Path) -> Path: + if os.name == "nt": + return venv_dir / "Scripts" / "python.exe" + return venv_dir / "bin" / "python" + + +@pytest.mark.slow +@pytest.mark.timeout(600) +def test_bare_install_imports_memory_only(tmp_path): + """Bare install: `Memory` works, `AsyncMemory` raises clear ImportError.""" + if not _REPO_PYPROJECT.exists(): + pytest.skip(f"pyproject.toml not found at {_REPO_PYPROJECT}") + if shutil.which(sys.executable) is None: + pytest.skip("host python is not invokable") + if sysconfig.get_config_var("Py_DEBUG"): + pytest.skip("debug build venv creation is unreliable") + + venv_dir = tmp_path / "bare-venv" + try: + subprocess.run( + [sys.executable, "-m", "venv", str(venv_dir)], + check=True, + capture_output=True, + timeout=120, + ) + except (subprocess.CalledProcessError, FileNotFoundError) as exc: + pytest.skip(f"cannot create venv on this host: {exc}") + + py = _venv_python(venv_dir) + if not py.exists(): + pytest.skip(f"venv python not found at {py}") + + repo_root = _REPO_PYPROJECT.parent + install = subprocess.run( + [str(py), "-m", "pip", "install", "--quiet", str(repo_root)], + capture_output=True, + text=True, + timeout=600, + ) + if install.returncode != 0: + pytest.skip( + "pip install of bare openmem failed in venv " + f"(stderr={install.stderr[-400:]!r})" + ) + + probe = subprocess.run( + [str(py), "-c", _PROBE_SCRIPT], + capture_output=True, + text=True, + timeout=60, + ) + assert probe.returncode == 0, ( + "Bare-install probe failed.\n" + f"--- stdout ---\n{probe.stdout}\n" + f"--- stderr ---\n{probe.stderr}" + ) diff --git a/specs/005-async-fastapi/checklists/requirements.md b/specs/005-async-fastapi/checklists/requirements.md new file mode 100644 index 0000000..b79913e --- /dev/null +++ b/specs/005-async-fastapi/checklists/requirements.md @@ -0,0 +1,40 @@ +# Specification Quality Checklist: M3.2 Async facade + FastAPI server + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-01 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [X] No implementation details (languages, frameworks, APIs) + - **Note**: FastAPI is named per explicit user request; flagged in Assumptions as a chosen framework. Adapter library names (asyncpg vs psycopg-async, httpx) are deliberately *not* named in requirements — left to plan phase. +- [X] Focused on user value and business needs +- [X] Written for non-technical stakeholders + - Caveat: User Stories 1 & 3 reference `asyncio` and event loops; this is unavoidable because the audience is Python developers, who are the only stakeholders for an async facade. +- [X] All mandatory sections completed + +## Requirement Completeness + +- [X] No [NEEDS CLARIFICATION] markers remain (0 of 3 used) +- [X] Requirements are testable and unambiguous +- [X] Success criteria are measurable (all SC-001..008 have numeric thresholds or pass/fail conditions) +- [X] Success criteria are technology-agnostic + - Caveat: SC-007 mentions `pip` and `ImportError`; both are unavoidable because the requirement *is* about the Python packaging surface. +- [X] All acceptance scenarios are defined +- [X] Edge cases are identified (8 listed including event-loop, cancellation-during-write, threadpool starvation, server backpressure) +- [X] Scope is clearly bounded (auth, streaming, CORS-by-default, WebSockets, new OpenAPI fields all explicitly out of scope) +- [X] Dependencies and assumptions identified (9 assumptions documented) + +## Feature Readiness + +- [X] All functional requirements have clear acceptance criteria +- [X] User scenarios cover primary flows (4 stories: async facade, server, cancellation, sync compat) +- [X] Feature meets measurable outcomes defined in Success Criteria +- [X] No implementation details leak into specification (modulo FastAPI / asyncio which are part of the *requirement*, not implementation choices) + +## Notes + +- **Story prioritization**: P1 = AsyncMemory (Story 1) + Server (Story 2) + Sync compatibility (Story 4); P2 = Cancellation (Story 3). All P1 stories are independently testable and any one of them shipped alone delivers value. +- **No clarification questions raised**: User input directly answered the four open questions (canonical sync, postgres+passthrough first, combined milestone, propagate cancellation). No ambiguity remains. +- **Combined-milestone risk**: Bundling AsyncMemory + Server doubles scope. The plan phase MUST decide whether to ship them in one PR or split into two PRs on the same branch. +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`. diff --git a/specs/005-async-fastapi/contracts/async-memory.md b/specs/005-async-fastapi/contracts/async-memory.md new file mode 100644 index 0000000..10c17f9 --- /dev/null +++ b/specs/005-async-fastapi/contracts/async-memory.md @@ -0,0 +1,82 @@ +# Contract: `AsyncMemory` + `AsyncBaseAdapter` (PR-A) + +This document specifies the binding contract for the async facade. +Implementations MUST satisfy every clause; tests in `tests/async/` enforce them. + +--- + +## 1. Verb signatures + +For every verb on `Memory`, `AsyncMemory` MUST expose the **same parameter list, defaults, and return type**, prefixed by `async`. + +A static check (`test_async_facade.py::test_signatures_match_sync`) MUST inspect both classes and assert equality of: +- positional/keyword parameter names +- default values +- type annotations (`get_type_hints` comparison) +- return type annotation +The async versions MUST additionally be coroutines (`inspect.iscoroutinefunction(...)`). + +## 2. Construction & lifecycle + +| Clause | Requirement | +|---|---| +| C-LIFE-1 | `AsyncMemory(provider="postgres", url="...")` returns within 5 ms on a laptop (no network/db I/O). | +| C-LIFE-2 | First verb call MAY take longer (lazy pool init); subsequent calls reuse the pool. | +| C-LIFE-3 | `async with AsyncMemory(...) as mem: ...` calls `__aenter__` (eager pool init) and `__aexit__` (close). | +| C-LIFE-4 | After `await mem.close()`, every verb call raises `RuntimeError("AsyncMemory is closed")`. | +| C-LIFE-5 | `await mem.close()` is idempotent (second call is a no-op, no exception). | + +## 3. Cancellation contract (FR-008, R3) + +| Clause | Adapter tier | Requirement | +|---|---|---| +| C-CAN-1 | Native (postgres, passthrough) | Cancelling a task awaiting any verb MUST cause `asyncio.CancelledError` to propagate to the awaiter within 50 ms. | +| C-CAN-2 | Native | The underlying connection/socket MUST be released to its pool within 500 ms of cancellation. Test asserts via `pool.size()` / `httpx.AsyncClient.is_closed`. | +| C-CAN-3 | Native (postgres only) | The server-side query MUST be aborted: `pg_stat_activity` MUST NOT show the cancelled query 1 second after cancellation. | +| C-CAN-4 | Best-effort (threadwrap) | Awaiter receives `CancelledError` immediately. Worker thread completes the wrapped sync call in the background. The wrapper logs at DEBUG when the orphaned call completes (visibility, not requirement). | +| C-CAN-5 | All tiers | Cancellation MUST NOT corrupt the adapter's pool/state — subsequent verb calls on the same `AsyncMemory` MUST succeed. | + +## 4. Cross-loop safety + +| Clause | Requirement | +|---|---| +| C-LOOP-1 | If `AsyncMemory` is constructed under loop A and a verb is awaited under loop B (different `id(asyncio.get_running_loop())`), the verb MUST raise `RuntimeError("AsyncMemory is bound to a different event loop")` BEFORE any backend call. | +| C-LOOP-2 | If `AsyncMemory` is constructed outside any loop (no `asyncio.get_running_loop()`), the loop is captured on first verb call instead of construction. | + +## 5. Error parity with sync `Memory` + +| Clause | Requirement | +|---|---| +| C-ERR-1 | For every condition where `Memory.` raises ``, `AsyncMemory.` MUST raise the SAME `` with an equivalent (string-equal where deterministic) message. | +| C-ERR-2 | `ProviderError.code` MUST match between sync and async for the same backend failure. | +| C-ERR-3 | `InvalidRequestError` for empty/whitespace `user_id` MUST be raised BEFORE any backend call (defends cross-user leakage). Validated by a test that uses a mock adapter whose methods raise on call. | + +## 6. Threadpool wrapper (`AsyncThreadwrapAdapter`) + +| Clause | Requirement | +|---|---| +| C-TW-1 | Wraps any object satisfying the existing sync `BaseAdapter` protocol. | +| C-TW-2 | Owns a `concurrent.futures.ThreadPoolExecutor` whose lifetime matches the wrapper. `close()` calls `executor.shutdown(wait=False, cancel_futures=True)`. | +| C-TW-3 | Forwards `wait_for_ingest` to the sync adapter via `run_in_executor`. The default no-op `BaseAdapter.wait_for_ingest` is preserved. | +| C-TW-4 | The wrapper MUST NOT mutate the wrapped sync adapter's state directly. All access goes through the executor. | + +## 7. Conformance test inventory (`tests/async/`) + +| File | What it tests | +|---|---| +| `test_async_facade.py` | Signature parity (§1), construction (§2), cross-loop safety (§4), close idempotency (C-LIFE-5). | +| `test_async_contract_lifecycle.py` | Parametrized over every adapter: add → get → update → list → delete cycle, return shapes match sync. | +| `test_async_contract_search.py` | Parametrized: `search` and `context` return same hit shape as sync; user_id scoping enforced. | +| `test_async_contract_errors.py` | Parametrized: every error class fires under the same conditions as sync (§5). | +| `test_async_cancellation.py` | Native-tier cancellation (§3, C-CAN-1..3) using the postgres + passthrough adapters. Threadwrap best-effort behavior (C-CAN-4) using a controllable mock adapter. | +| `test_async_threadwrap.py` | Threadwrap-specific: executor isolation per instance, shutdown on close, sync-state non-mutation. | + +All tests MUST pass with `pytest-asyncio` auto-mode and contribute to the existing 85% coverage gate. + +## 8. Imports & extras + +| Clause | Requirement | +|---|---| +| C-EXT-1 | `from openmem import AsyncMemory` works iff `pip install openmem[async]` was run. | +| C-EXT-2 | If the `[async]` extras are missing, the import raises `ImportError` whose message contains the exact string `pip install 'openmem[async]'`. | +| C-EXT-3 | `import openmem` (without using `AsyncMemory`) MUST succeed even when `[async]` extras are absent. | diff --git a/specs/005-async-fastapi/contracts/http-server.md b/specs/005-async-fastapi/contracts/http-server.md new file mode 100644 index 0000000..b4d99be --- /dev/null +++ b/specs/005-async-fastapi/contracts/http-server.md @@ -0,0 +1,137 @@ +# Contract: HTTP server (`omp-server`) (PR-B) + +This document specifies the binding contract for the FastAPI passthrough server. +Every clause is enforced by tests in `tests/server/`. +The OpenAPI spec at `spec/omp-0.1.openapi.yaml` is the **canonical** source for paths/schemas/codes — anything below that diverges is a bug in this document. + +--- + +## 1. Routes (mirrors `spec/omp-0.1.openapi.yaml`) + +| Method | Path | Handler | Success status | Returns | +|---|---|---|---|---| +| `POST` | `/v1/memories` | `add` | `201 Created` | `Memory` | +| `GET` | `/v1/memories/{id}` | `get` | `200 OK` | `Memory` | +| `PATCH` | `/v1/memories/{id}` | `update` | `200 OK` | `Memory` | +| `DELETE` | `/v1/memories/{id}` | `delete` | `204 No Content` | empty | +| `GET` | `/v1/memories` | `list` | `200 OK` | `MemoryPage` | +| `POST` | `/v1/search` | `search` | `200 OK` | `list[SearchHit]` | +| `POST` | `/v1/context` | `context` | `200 OK` | `ContextBlock` | +| `GET` | `/v1/capabilities` | `capabilities` | `200 OK` | `Capabilities` | +| `GET` | `/healthz` | `health` | `200 OK` / `503` | `{"status":"ok"}` or Error | + +Every route handler is `async def` and obtains the `AsyncMemory` via `Depends(get_memory)`. + +## 2. `user_id` propagation + +| Clause | Requirement | +|---|---| +| C-UID-1 | All verb routes accept `user_id` from the JSON request body (POST/PATCH) or from the `X-User-Id` header (GET/DELETE). | +| C-UID-2 | If `user_id` is missing or empty/whitespace, the server MUST respond `400` with `code = invalid_request` BEFORE calling the adapter. | +| C-UID-3 | `user_id` MUST NEVER appear in any log line (FR-020). | + +## 3. Error envelope mapping (FR-016, FR-017) + +```json +{ "error": { "code": "", "message": "", "details": { ... } } } +``` + +| Exception | HTTP | `code` | +|---|---|---| +| `NotFoundError` | 404 | `not_found` | +| `InvalidRequestError` | 400 | `invalid_request` | +| `UnauthorizedError` | 401 | `unauthorized` | +| `ScopeDeniedError` | 403 | `scope_denied` | +| `RateLimitedError` | 429 | `rate_limited` | +| `UnsupportedCapabilityError` | 405 | `unsupported_capability` | +| `ProviderError(code="ingestion_timeout")` | 504 | `ingestion_timeout` | +| `ProviderError(other)` | 502 | `provider_error` | +| Unhandled `Exception` | 500 | `internal_error` | +| Body too large (FR-021) | 413 | `payload_too_large` | +| Pool exhausted (FR-019 path) | 503 | `provider_unavailable` | + +Test (`test_server_errors.py`) parametrizes over every row. + +## 4. Request size limit (FR-021) + +| Clause | Requirement | +|---|---| +| C-SIZ-1 | Request bodies > `OmpServerConfig.max_request_bytes` (default 1 MiB) MUST return `413` with `code = payload_too_large` BEFORE Pydantic validation runs. | +| C-SIZ-2 | The check MUST inspect `Content-Length`; if absent, the server reads the body in bounded chunks and aborts at the limit. | + +## 5. CORS (FR-022) + +| Clause | Requirement | +|---|---| +| C-CORS-1 | If `OmpServerConfig.cors_origins` is empty (default), CORS middleware is NOT installed. Every cross-origin request gets the browser's standard CORS rejection. | +| C-CORS-2 | If non-empty, FastAPI's `CORSMiddleware` is installed with `allow_origins=list(cors_origins)`, `allow_credentials=False`, `allow_methods=["GET","POST","PATCH","DELETE"]`, `allow_headers=["Content-Type","X-User-Id","X-Request-Id"]`. | + +## 6. Health endpoint (FR-019) + +| Clause | Requirement | +|---|---| +| C-HEA-1 | `GET /healthz` is exempt from the `user_id` check. | +| C-HEA-2 | For `postgres`: acquire+release a pool connection within 1 s timeout; success → 200, timeout → 503. | +| C-HEA-3 | For `passthrough`: `HEAD ` within 2 s; 2xx/3xx → 200, else 503. | +| C-HEA-4 | For `mem0`/`supermemory`/`letta`: returns 200 unconditionally (paid endpoint protection). MUST be documented in `--help` output and the route docstring. | + +## 7. Logging (FR-020) + +Format: ` ms req=` + +| Clause | Requirement | +|---|---| +| C-LOG-1 | Every request emits exactly one log line at INFO. | +| C-LOG-2 | Log lines MUST NOT contain: request body, response body, `user_id`, any header beginning with `Authorization`, any field whose key matches `(?i)password|secret|token|key|api_key`. | +| C-LOG-3 | `request_id` is `X-Request-Id` from the request if present (and matches `^[A-Za-z0-9._\-]{1,64}$`), else a fresh UUID4. The same id is set on the response as `X-Request-Id`. | +| C-LOG-4 | A test (`test_server_logging.py`) injects a sensitive payload and asserts no forbidden substring appears in captured log output. | + +## 8. OpenAPI conformance (FR-015) + +Test `test_server_openapi_conformance.py`: +1. Loads `spec/omp-0.1.openapi.yaml` once per session into a `jsonschema` resolver. +2. For each route × representative success+error case (≈25 cases), issues the request via `httpx.AsyncClient(app=app)`. +3. Validates the response body against the matching `responses[].content["application/json"].schema` from the spec. +4. Asserts `Content-Type: application/json` for every JSON body. +5. Asserts `X-Request-Id` is echoed. + +PR-B's coverage gate requires this test to pass at 100% (no skips). + +## 9. Cancellation on client disconnect (FR-018) + +| Clause | Requirement | +|---|---| +| C-DIS-1 | When the ASGI scope reports `http.disconnect` for an in-flight request, the route task MUST be cancelled. | +| C-DIS-2 | Cancellation MUST propagate through `AsyncMemory.` and abort backend work per the AsyncMemory cancellation contract. | +| C-DIS-3 | Test simulates client disconnect via `httpx.AsyncClient` cancellation and asserts the postgres pool's `size_used` returns to baseline within 1 s. | + +## 10. CLI (`omp-server`) + +``` +omp-server --provider [--host ] [--port

] + [--max-request-bytes ] [--cors-origins ] + [--log-level ] + [provider-specific flags...] +``` + +Provider-specific flags: +- `--url ` (postgres) +- (mem0/supermemory/letta): no flag; reads `*_API_KEY` from env + +Every flag has a matching env var (per FR-013). CLI > env > default. + +| Clause | Requirement | +|---|---| +| C-CLI-1 | `omp-server --help` text MUST contain the literal phrase "trusted-network deployment only" and "auth deferred" (FR-023). | +| C-CLI-2 | `omp-server --version` prints `omp-server ` and exits 0. | +| C-CLI-3 | Missing required provider config (e.g. postgres without `--url`/`OMP_POSTGRES_URL`) exits 2 with stderr message starting `omp-server: missing config:`. | +| C-CLI-4 | Successful boot prints `omp-server: serving at http://:` to stderr exactly once. | + +## 11. Out of scope (explicit non-goals for v1) + +- Authentication / Authorization (no API key check, no bearer token, no mTLS) +- Streaming endpoints (SSE, WebSocket) +- Multi-tenant routing (one provider per server process) +- Metrics endpoint (`/metrics` Prometheus) — deferred +- Async pool warming during startup beyond `await mem.__aenter__()` +- Graceful drain during shutdown (uvicorn defaults are accepted) diff --git a/specs/005-async-fastapi/data-model.md b/specs/005-async-fastapi/data-model.md new file mode 100644 index 0000000..437f600 --- /dev/null +++ b/specs/005-async-fastapi/data-model.md @@ -0,0 +1,231 @@ +# Phase 1 Data Model — M3.2 Async facade + FastAPI server + +This document defines the entities, protocols, and invariants for the M3.2 implementation. No new schema fields are introduced; all data structures reuse the existing pydantic models from `openmem/types.py`. + +--- + +## 1. `AsyncMemory` (PR-A) + +The user-facing async facade. Mirrors `Memory` 1:1. + +### Public surface + +```python +class AsyncMemory: + def __init__( + self, + provider: Literal["postgres", "passthrough", "mem0", "supermemory", "letta"], + *, + url: str | None = None, + api_key: str | None = None, + embedder: Embedder | None = None, + executor_max_workers: int | None = None, # NEW vs Memory; only used by threadwrap + **provider_kwargs: Any, + ) -> None: ... + + async def add(self, content: str, user_id: str, *, tags: Sequence[str] | None = None, + scope: str | None = None, metadata: Mapping[str, Any] | None = None) -> MemoryRecord: ... + async def get(self, id: str, user_id: str) -> MemoryRecord: ... + async def search(self, query: str, user_id: str, *, limit: int = 10, + scope: str | None = None) -> list[SearchHit]: ... + async def list(self, user_id: str, *, scope: str | None = None, + cursor: str | None = None, limit: int = 50) -> MemoryPage: ... + async def update(self, id: str, user_id: str, *, content: str | None = None, + tags: Sequence[str] | None = None, + metadata: Mapping[str, Any] | None = None) -> MemoryRecord: ... + async def delete(self, id: str, user_id: str) -> None: ... + async def context(self, query: str, user_id: str, *, limit: int = 5) -> ContextBlock: ... + async def capabilities(self) -> Capabilities: ... + async def wait_for_ingest(self, ids: Sequence[str], user_id: str, + *, timeout: float | None = None) -> None: ... + + async def close(self) -> None: ... + async def __aenter__(self) -> "AsyncMemory": ... + async def __aexit__(self, *exc) -> None: ... +``` + +### Invariants + +| ID | Invariant | +|---|---| +| AM-INV-1 | All return types are **identical** to the corresponding `Memory.*` return types. No new types are introduced. | +| AM-INV-2 | All raised exception classes are **identical** to `Memory.*` exceptions. | +| AM-INV-3 | `__init__` performs ZERO blocking I/O. Connection pool / HTTP client / threadpool creation MAY happen lazily on first verb call OR on `__aenter__`, but never in `__init__`. | +| AM-INV-4 | `close()` is idempotent. The second and subsequent calls are no-ops. | +| AM-INV-5 | After `close()`, every verb call MUST raise `RuntimeError("AsyncMemory is closed")` BEFORE touching any backend resource. | +| AM-INV-6 | Cross-event-loop misuse is detected: if `_loop_id` captured at first-use differs from `id(asyncio.get_running_loop())`, raise `RuntimeError("AsyncMemory is bound to a different event loop")`. | +| AM-INV-7 | The `executor_max_workers` kwarg is silently ignored by `async_postgres` and `async_passthrough` adapters (they don't use a threadpool). It MUST NOT raise. | + +### State + +- `_adapter: AsyncBaseAdapter` — the underlying async adapter +- `_closed: bool = False` +- `_loop_id: int | None = None` — set on first verb call +- `_executor: ThreadPoolExecutor | None = None` — only created by threadwrap adapter; owned by `AsyncMemory` so `close()` can shut it down + +### Lifecycle + +``` +__init__() → _closed=False, _adapter selected by provider, no I/O +first verb → capture _loop_id; ensure adapter pool/client initialized + ⋮ +close() → await adapter.close(); shut down executor; _closed=True +``` + +--- + +## 2. `AsyncBaseAdapter` (PR-A) + +Internal protocol implemented by every async adapter. + +### Protocol + +```python +class AsyncBaseAdapter(Protocol): + async def add(self, content: str, user_id: str, **kw) -> MemoryRecord: ... + async def get(self, id: str, user_id: str) -> MemoryRecord: ... + async def search(self, query: str, user_id: str, *, limit: int) -> list[SearchHit]: ... + async def list(self, user_id: str, *, cursor: str | None, limit: int) -> MemoryPage: ... + async def update(self, id: str, user_id: str, **kw) -> MemoryRecord: ... + async def delete(self, id: str, user_id: str) -> None: ... + async def context(self, query: str, user_id: str, *, limit: int) -> ContextBlock: ... + async def capabilities(self) -> Capabilities: ... + async def wait_for_ingest(self, ids: Sequence[str], user_id: str, + *, timeout: float | None) -> None: ... + async def close(self) -> None: ... +``` + +### Concrete implementations (PR-A) + +| Class | Module | Cancellation tier (R3) | Notes | +|---|---|---|---| +| `AsyncPostgresAdapter` | `adapters/async_postgres.py` | Native | Uses `asyncpg.create_pool`; shares SQL strings with sync `postgres.py` via new `_postgres_sql.py` module. | +| `AsyncPassthroughAdapter` | `adapters/async_passthrough.py` | Native | Uses `httpx.AsyncClient`; transport defaults match sync `passthrough.py` (timeout, retries, headers). | +| `AsyncThreadwrapAdapter` | `adapters/async_threadwrap.py` | Best-effort | Constructed with a sync adapter instance + an executor; every method is `await asyncio.get_running_loop().run_in_executor(self._executor, sync_method, *args)`. | + +`AsyncMemory.__init__` selects: + +```python +sync_only = {"mem0", "supermemory", "letta"} +if provider == "postgres": adapter = AsyncPostgresAdapter(...) +elif provider == "passthrough": adapter = AsyncPassthroughAdapter(...) +elif provider in sync_only: adapter = AsyncThreadwrapAdapter(make_sync_adapter(provider, ...), executor) +``` + +--- + +## 3. `OmpServerConfig` (PR-B) + +Dataclass capturing CLI args + env defaults. + +```python +@dataclass(frozen=True) +class OmpServerConfig: + provider: str # OMP_PROVIDER + host: str = "127.0.0.1" # OMP_HOST + port: int = 8080 # OMP_PORT + max_request_bytes: int = 1024 * 1024 # OMP_MAX_REQUEST_BYTES, 1 MiB + cors_origins: tuple[str, ...] = () # OMP_CORS_ORIGINS (comma split) + log_level: str = "info" # OMP_LOG_LEVEL + + # Provider-specific + postgres_url: str | None = None # OMP_POSTGRES_URL or PG_URL + mem0_api_key: str | None = None # MEM0_API_KEY + supermemory_api_key: str | None = None # SUPERMEMORY_API_KEY + letta_api_key: str | None = None # LETTA_API_KEY +``` + +### Invariants + +| ID | Invariant | +|---|---| +| CFG-INV-1 | `port` MUST be in `1..65535` else `ValueError` at construction. | +| CFG-INV-2 | `max_request_bytes` MUST be in `1024..104_857_600` (1 KiB..100 MiB). | +| CFG-INV-3 | If `provider == "postgres"`, `postgres_url` MUST be non-empty. | +| CFG-INV-4 | If `provider in {"mem0","supermemory","letta"}`, the matching API key MUST be non-empty. | + +--- + +## 4. FastAPI app structure (PR-B) + +``` +openmem.server.app.create_app(config: OmpServerConfig) -> FastAPI +``` + +`create_app`: +1. Builds the AsyncMemory: `mem = AsyncMemory(provider=config.provider, **resolved_kwargs)`. +2. Registers it as an app-state singleton: `app.state.memory = mem`. +3. Mounts routers from `routes.py`. +4. Registers exception handlers from `errors.py`. +5. Adds startup hook to `await mem.__aenter__` (warm pool) and shutdown hook to `await mem.close()`. + +### Dependency injection + +```python +async def get_memory(request: Request) -> AsyncMemory: + return request.app.state.memory +``` + +Used by every route handler via `mem: AsyncMemory = Depends(get_memory)`. + +--- + +## 5. Error envelope (reused from `spec/omp-0.1.openapi.yaml`) + +```json +{ "error": { "code": "", "message": "", "details": { ... } } } +``` + +Mapping (FR-017): + +| Exception class | HTTP status | `code` | +|---|---|---| +| `NotFoundError` | 404 | `not_found` | +| `InvalidRequestError` | 400 | `invalid_request` | +| `UnauthorizedError` | 401 | `unauthorized` | +| `ScopeDeniedError` | 403 | `scope_denied` | +| `RateLimitedError` | 429 | `rate_limited` | +| `UnsupportedCapabilityError` | 405 | `unsupported_capability` | +| `ProviderError(code="ingestion_timeout")` | 504 | `ingestion_timeout` | +| `ProviderError` (any other) | 502 | `provider_error` | +| `Exception` (unhandled) | 500 | `internal_error` | + +The `details` field MAY be omitted on success or sanitized to omit `user_id`, headers, and any field beginning with `api_`. + +--- + +## 6. Logging contract (PR-B) + +Every request log line contains: + +``` + req= +``` + +Forbidden (per FR-020): request body, response body, `user_id`, `api_key`, `Authorization` header, any field whose name matches `(?i)password|secret|token|key`. + +Implementation: a custom `LoggingMiddleware` that constructs the line from `Request.method`, `Request.url.path`, `response.status_code`, the elapsed time, and a per-request UUID4. The middleware MUST NOT touch `request.body()`. + +--- + +## 7. Health endpoint (PR-B, FR-019) + +``` +GET /healthz + 200 OK {"status": "ok"} # adapter reachable + 503 {"error": {"code": "provider_unavailable", "message": ""}} # not reachable +``` + +Implementation: +- For `postgres`: `await pool.acquire(timeout=1.0)` then immediate release. SELECT 1 NOT executed (avoids waking pgvector hot path). +- For `passthrough`: `await client.head(base_url, timeout=2.0)`. +- For mem0/supermemory/letta: returns `200 OK` unconditionally — health-checking paid endpoints would burn quota. Documented in the contract. + +--- + +## 8. Conformance test fixtures (PR-A & PR-B) + +Reuse the existing `tests/conftest.py` patterns: +- `_make_async_memory(provider, **kw)` factory mirroring the existing `_make_memory`. +- `pytest_asyncio.fixture` for per-test `AsyncMemory` instances with auto-cleanup via `await mem.close()` in finalizer. +- Live-mode finalizer tracks created ids and `await mem.delete(id)` at teardown (FR-119 from M2.1, applied to async). diff --git a/specs/005-async-fastapi/plan.md b/specs/005-async-fastapi/plan.md new file mode 100644 index 0000000..05abd07 --- /dev/null +++ b/specs/005-async-fastapi/plan.md @@ -0,0 +1,130 @@ +# Implementation Plan: M3.2 Async facade + FastAPI passthrough server + +**Branch**: `005-async-fastapi` | **Date**: 2026-05-01 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `specs/005-async-fastapi/spec.md` + +## Summary + +Add an `AsyncMemory` facade that mirrors `Memory` 1:1 with `async def` verbs and a FastAPI passthrough server (`omp-server`) that mounts the OpenAPI surface 1:1. Postgres and passthrough adapters get **native** async implementations (`asyncpg`, `httpx.AsyncClient`); the three remote SDK adapters (mem0, supermemory, letta) are wrapped via `asyncio.to_thread` because their upstream SDKs are sync-only. Existing sync `Memory` is **untouched** — the async surface is additive. Cancellation propagates end-to-end and aborts in-flight backend work on async-native adapters. + +**Per user direction**: ship as **two PRs on the same `005-async-fastapi` branch** (sequential, not parallel): + +| PR | Scope | Stories satisfied | Gates | +|---|---|---|---| +| **PR-A — AsyncMemory + cancellation** | `openmem.AsyncMemory`, `AsyncBaseAdapter`, async postgres + passthrough adapters, threadpool wrapper for mem0/supermemory/letta, `[async]` extra | US1, US3, US4 (compat) | Existing 365 sync tests still pass; new async-conformance suite green; cancellation test shows pool-release ≤500 ms | +| **PR-B — FastAPI passthrough server** | `openmem.server` package, `omp-server` console script, OpenAPI conformance test suite, `[server]` extra, error-mapping table | US2 | OpenAPI conformance 100%; `omp-server` boots <2 s; SC-001 RPS benchmark passes | + +PR-A is a hard prerequisite for PR-B (the server holds an `AsyncMemory`). PR-B opens against PR-A's merge commit. + +## Technical Context + +**Language/Version**: Python ≥3.11 (matches existing `requires-python`) +**Primary Dependencies**: +- PR-A: `asyncpg>=0.29` (async postgres), `httpx>=0.27` (async via `httpx.AsyncClient`; already a sync dep, version-bump only), stdlib `asyncio` + `concurrent.futures` +- PR-B: `fastapi>=0.115`, `uvicorn[standard]>=0.30`, existing `pydantic>=2` types from M1 +**Storage**: Reuses existing per-provider stores (postgres+pgvector, mem0/supermemory/letta hosted) +**Testing**: pytest 9.x + `pytest-asyncio>=0.24` (new dev dep) + `httpx.AsyncClient` (test client for FastAPI) +**Target Platform**: Linux/macOS/Windows; Python ≥3.11. Server: any ASGI host (uvicorn primary). +**Project Type**: Library + optional HTTP server (single Python project, two packaging extras) +**Performance Goals**: SC-001 ≥10× sync RPS; SC-002 100 concurrent adds ≤2× single-add latency; SC-003 cancellation pool-release ≤500 ms; SC-006 server boot <2 s +**Constraints**: Zero change to sync `Memory` public surface (FR-011); unauthenticated server (auth deferred); no new OpenAPI fields (consume `spec/omp-0.1.openapi.yaml` as-is); Python 3.11 minimum to allow `asyncio.TaskGroup` and structured cancellation +**Scale/Scope**: ≈1500 LOC implementation across PRs (≈900 PR-A, ≈600 PR-B); ≈40 new tests; +2 packaging extras + +## Constitution Check + +| Principle | How this plan satisfies it | +|---|---| +| **I. Spec-First, Single Source of Truth (NON-NEGOTIABLE)** | Zero changes to `spec/omp-0.1.openapi.yaml`. The FastAPI server **consumes** the spec — every route, request schema, response schema, and error code is mounted from the existing OpenAPI document. A conformance test (PR-B) asserts every server response validates against the spec's schemas. AsyncMemory adds no new verbs/fields/error codes. | +| **II. Adapter Conformance via Shared Contract Tests (NON-NEGOTIABLE)** | A new `tests/async/test_async_contract_*.py` suite mirrors the existing sync `tests/test_contract_*.py` files and parametrizes over every adapter. Each async adapter MUST pass the same lifecycle/search/errors/compat assertions as its sync counterpart. The threadpool-wrapped adapters reuse the existing sync adapter for the actual call but still run the async contract suite to prove the wrapper preserves semantics. | +| **III. Backward and Forward Compatibility** | `Memory` is **byte-identical** post-change (FR-011, SC-008). `AsyncMemory` is purely additive — no rename, no signature change. Both extras (`[async]`, `[server]`) are opt-in; a bare `pip install openmem` continues to install only the existing sync stack (FR-025, SC-007). No required field/verb removed. Unknown fields still tolerated (we use the existing pydantic models unchanged). | +| **IV. Provider Neutrality and User Sovereignty** | The server defaults to **postgres** (reference path) and works with no third-party account. No vendor coupling: AsyncMemory supports all five existing providers from day one. `user_id` and scoping primitives unchanged; the server enforces empty-`user_id` rejection at the boundary (FR-016 reuses `code = invalid_request`). The server is unauthenticated by design — no telemetry, no license keys, no hosted dependency. | +| **V. Open Extensibility via Namespaced Fields** | No new top-level fields. All existing `x-mem0` / `x-supermemory` / `x-letta` extension fields pass through both `AsyncMemory` and the server unchanged because we reuse the existing pydantic models. The server does NOT strip extension fields on response. | + +**Result**: All five principles satisfied. **No violations** → no Complexity Tracking entries needed. + +## Project Structure + +### Documentation (this feature) + +```text +specs/005-async-fastapi/ +├── plan.md # This file +├── research.md # Phase 0 — driver/cancellation/extras decisions +├── data-model.md # Phase 1 — AsyncMemory & OmpServer entities + protocols +├── quickstart.md # Phase 1 — usage walkthrough for both PRs +├── contracts/ +│ ├── async-memory.md # Phase 1 — AsyncMemory + AsyncBaseAdapter contract +│ └── http-server.md # Phase 1 — HTTP route/error mapping contract +├── checklists/ +│ └── requirements.md # Already exists — passed all 16 items +└── tasks.md # Phase 2 — generated by /speckit.tasks +``` + +### Source Code (repository root) + +```text +sdk-python/ +├── openmem/ +│ ├── __init__.py # MODIFIED: lazy re-export AsyncMemory +│ ├── memory.py # UNCHANGED (FR-011) +│ ├── async_memory.py # NEW (PR-A): AsyncMemory facade +│ ├── adapters/ +│ │ ├── async_base.py # NEW (PR-A): AsyncBaseAdapter protocol +│ │ ├── async_postgres.py # NEW (PR-A): asyncpg-backed adapter +│ │ ├── async_passthrough.py # NEW (PR-A): httpx.AsyncClient adapter +│ │ ├── async_threadwrap.py # NEW (PR-A): wraps any sync adapter +│ │ ├── postgres.py # UNCHANGED +│ │ ├── passthrough.py # UNCHANGED +│ │ ├── mem0.py / supermemory.py / letta.py # UNCHANGED +│ │ └── _ingest.py / _cursor.py / _http.py # UNCHANGED +│ └── server/ # NEW (PR-B) +│ ├── __init__.py # exports `app`, `create_app(config)` +│ ├── app.py # FastAPI app factory + route registration +│ ├── routes.py # one function per OpenAPI path +│ ├── errors.py # exception → HTTP status + Error envelope +│ ├── deps.py # AsyncMemory dependency-injection +│ └── cli.py # `omp-server` console script entry +└── tests/ + ├── async/ # NEW (PR-A) + │ ├── test_async_contract_lifecycle.py + │ ├── test_async_contract_search.py + │ ├── test_async_contract_errors.py + │ ├── test_async_facade.py + │ ├── test_async_cancellation.py + │ └── test_async_threadwrap.py + └── server/ # NEW (PR-B) + ├── test_server_routes.py + ├── test_server_errors.py + ├── test_server_openapi_conformance.py + ├── test_server_health.py + └── test_server_cli.py +``` + +**Structure Decision**: **Single Python project with two packaging extras**. AsyncMemory and the server live alongside the existing sync stack inside the `openmem` package; their dependencies are gated by `[async]` and `[server]` extras (FR-025) so the bare install stays slim. No new top-level project / no monorepo split is justified — the server is a thin FastAPI wrapper over `AsyncMemory` and shares the same release cadence. This matches Option 1 (single project) from the template. + +## Phase progress + +- **Phase 0 (research)**: ✅ See [research.md](research.md) — async postgres driver, cancellation strategy, threadpool sizing, extras layout, OpenAPI mounting strategy. +- **Phase 1 (design)**: ✅ See [data-model.md](data-model.md), [contracts/async-memory.md](contracts/async-memory.md), [contracts/http-server.md](contracts/http-server.md), [quickstart.md](quickstart.md). +- **Phase 2 (tasks)**: deferred to `/speckit.tasks` (per workflow). +- **Agent context update**: `.github/copilot-instructions.md` repointed in this plan run to `specs/005-async-fastapi/plan.md`. + +## Re-evaluated Constitution Check (post-design) + +After producing data-model.md and the two contracts: + +- **Principle I**: Re-affirmed. The `http-server.md` contract derives every route/status/code directly from `spec/omp-0.1.openapi.yaml`; no field is invented. +- **Principle II**: Re-affirmed. The async contract suite is a near-mechanical transform of the existing sync suite (`async def`, `await`, `pytest.mark.asyncio`); no new assertion classes. +- **Principle III**: Re-affirmed. Sync `Memory` and the existing adapters are untouched. The `__init__.py` change is a guarded lazy re-export that fails *only* when the `[async]` extra is missing, which is correct behavior, not a break. +- **Principle IV**: Re-affirmed. Postgres remains the default and the only fully-self-hosted path through both PRs. +- **Principle V**: Re-affirmed. No new top-level fields anywhere. + +**Verdict**: All gates pass. Proceed to `/speckit.tasks`. + +## Complexity Tracking + +> No constitution violations. Table empty. + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| *(none)* | — | — | diff --git a/specs/005-async-fastapi/quickstart.md b/specs/005-async-fastapi/quickstart.md new file mode 100644 index 0000000..8fa3d22 --- /dev/null +++ b/specs/005-async-fastapi/quickstart.md @@ -0,0 +1,228 @@ +# Quickstart — M3.2 Async facade + FastAPI server + +A short walkthrough of both deliverables once they ship. Use this to validate the implementation post-merge. + +--- + +## Install + +```bash +# Bare install — sync Memory only (existing behavior, unchanged) +pip install openmem + +# AsyncMemory facade +pip install 'openmem[async]' + +# AsyncMemory + FastAPI HTTP server +pip install 'openmem[server]' +``` + +Verify: + +```python +from openmem import Memory # always works +from openmem import AsyncMemory # requires [async] extra +``` + +--- + +## PR-A — `AsyncMemory` + +### 1. Single-call usage + +```python +import asyncio +from openmem import AsyncMemory + +async def main(): + async with AsyncMemory(provider="postgres", url="postgresql://...") as mem: + rec = await mem.add(content="user prefers pnpm", user_id="u1") + hits = await mem.search("package manager", "u1", limit=5) + for h in hits: + print(h.memory.content) + +asyncio.run(main()) +``` + +### 2. Concurrent fan-out (the headline benefit) + +```python +async def ingest_many(): + async with AsyncMemory(provider="postgres", url="...") as mem: + # 100 inserts in parallel — completes in ~single-insert latency + await asyncio.gather(*[ + mem.add(content=fact, user_id="u1") for fact in 100_facts + ]) +``` + +### 3. With a sync-only backend (mem0/supermemory/letta) + +```python +async with AsyncMemory(provider="mem0", api_key="...") as mem: + # Internally wrapped in a threadpool — event loop never blocks + rec = await mem.add(content="hi", user_id="u1") +``` + +The threadpool size defaults to `min(32, cpu_count + 4)`. Override: + +```python +AsyncMemory(provider="mem0", api_key="...", executor_max_workers=4) +``` + +### 4. Cancellation (postgres + passthrough) + +```python +async def cancel_demo(): + async with AsyncMemory(provider="postgres", url="...") as mem: + task = asyncio.create_task(mem.search("slow query", "u1")) + await asyncio.sleep(0.1) + task.cancel() + try: + await task + except asyncio.CancelledError: + print("cancelled — postgres pool reclaimed within 500ms") +``` + +For `mem0`/`supermemory`/`letta` (threadwrap), the awaiter sees `CancelledError` immediately but the worker thread completes its in-flight call in the background. The backend MAY have observable side-effects. + +### 5. Sync `Memory` is unchanged + +```python +from openmem import Memory # zero changes from M3.1 +mem = Memory(provider="postgres", url="...") +mem.add(content="hi", user_id="u1") # blocks the thread, exactly as before +``` + +--- + +## PR-B — `omp-server` + +### 1. Boot the server + +```bash +# Postgres-backed (recommended for local dev) +export OMP_POSTGRES_URL="postgresql://postgres:postgres@localhost:5432/postgres" +omp-server --provider postgres --port 8080 + +# Output: +# omp-server: serving postgres at http://127.0.0.1:8080 +``` + +Or via flags only: + +```bash +omp-server --provider postgres --url postgresql://... --host 0.0.0.0 --port 8080 +``` + +For mem0: + +```bash +export MEM0_API_KEY="mem0-..." +omp-server --provider mem0 --port 8080 +``` + +### 2. Smoke test + +```bash +# Health +curl http://localhost:8080/healthz +# {"status":"ok"} + +# Add +curl -X POST http://localhost:8080/v1/memories \ + -H "Content-Type: application/json" \ + -d '{"content":"user prefers pnpm","user_id":"u1"}' +# 201 Created +# {"id":"...","content":"user prefers pnpm","user_id":"u1","created_at":"...","tags":[],"metadata":{}} + +# Search +curl -X POST http://localhost:8080/v1/search \ + -H "Content-Type: application/json" \ + -d '{"query":"package manager","user_id":"u1","limit":5}' +# 200 OK +# [{"memory":{"id":"...","content":"...","user_id":"u1",...},"score":0.83}] + +# List +curl "http://localhost:8080/v1/memories?limit=10" \ + -H "X-User-Id: u1" + +# Capabilities +curl http://localhost:8080/v1/capabilities +# {"omp_version":"0.1","verbs":["add","get","search","list","update","delete","capabilities"],...} + +# Delete +curl -X DELETE http://localhost:8080/v1/memories/ \ + -H "X-User-Id: u1" +# 204 No Content +``` + +### 3. Error cases + +```bash +# Missing user_id → 400 invalid_request +curl -X POST http://localhost:8080/v1/memories \ + -H "Content-Type: application/json" \ + -d '{"content":"hi"}' +# {"error":{"code":"invalid_request","message":"user_id is required"}} + +# Unknown id → 404 not_found +curl http://localhost:8080/v1/memories/does-not-exist -H "X-User-Id: u1" +# {"error":{"code":"not_found","message":"..."}} + +# Body too large → 413 +curl -X POST http://localhost:8080/v1/memories \ + -H "Content-Type: application/json" \ + --data-binary @huge.json +# {"error":{"code":"payload_too_large","message":"max 1048576 bytes"}} +``` + +### 4. CORS (opt-in) + +```bash +omp-server --provider postgres --url ... \ + --cors-origins "https://app.example.com,https://staging.example.com" +``` + +By default no CORS middleware is installed (FR-022). + +### 5. From an async Python client + +```python +import httpx + +async with httpx.AsyncClient(base_url="http://localhost:8080") as client: + r = await client.post("/v1/memories", json={"content":"hi","user_id":"u1"}) + rec = r.json() + r = await client.post("/v1/search", json={"query":"hi","user_id":"u1"}) + hits = r.json() +``` + +The same code works against any provider — switch the server's `--provider` flag. + +--- + +## Validation against success criteria + +| Success Criterion | How to verify | +|---|---| +| **SC-001** ≥10× sync RPS | Run `tests/server/test_throughput_bench.py::test_postgres_async_vs_sync` (auto-skipped without `OMP_POSTGRES_URL`) | +| **SC-002** Concurrent fan-out | Run example 2 above; assert wall time < 2× single-call latency | +| **SC-003** Cancellation pool-release ≤500 ms | Run `tests/async/test_async_cancellation.py::test_postgres_pool_release` | +| **SC-004** Existing tests unchanged | `pytest sdk-python/tests` — must report `365 passed` (the M3.1 baseline) before counting new async/server tests | +| **SC-005** OpenAPI conformance 100% | Run `tests/server/test_server_openapi_conformance.py` | +| **SC-006** Boot <2 s, first request <100 ms | Run `tests/server/test_server_cli.py::test_boot_time` | +| **SC-007** Bare install preserves sync surface | Run in a fresh venv: `pip install openmem; python -c "from openmem import Memory; from openmem import AsyncMemory"` — second import MUST raise `ImportError` with `pip install 'openmem[async]'` in message | +| **SC-008** `Memory` byte-identical | Run `tests/async/test_async_facade.py::test_memory_signatures_unchanged` | + +--- + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `ImportError: AsyncMemory requires the [async] extra` | Bare install | `pip install 'openmem[async]'` | +| `RuntimeError: AsyncMemory is bound to a different event loop` | Reusing the instance across `asyncio.run` calls | Construct a new `AsyncMemory` per loop, or use `async with` to scope it | +| `RuntimeError: AsyncMemory is closed` | Used after `close()` | Construct a new instance | +| `omp-server: missing config: postgres requires --url or OMP_POSTGRES_URL` | No DB URL provided | Set the env var or pass `--url` | +| Server returns `503 provider_unavailable` | Postgres unreachable, pool exhausted, or paid backend down | Check `omp-postgres` container / API keys; check `/healthz` | +| Logs contain `user_id` | **Bug — file an issue.** Logging contract C-LOG-2 forbids it. | Report the offending log line + commit SHA | diff --git a/specs/005-async-fastapi/research.md b/specs/005-async-fastapi/research.md new file mode 100644 index 0000000..1127f44 --- /dev/null +++ b/specs/005-async-fastapi/research.md @@ -0,0 +1,207 @@ +# Phase 0 Research — M3.2 Async facade + FastAPI server + +This document records the technical decisions made during research, with rationale and rejected alternatives, before any code is written. + +--- + +## R1 — Async PostgreSQL driver: `asyncpg` vs `psycopg[async]` + +**Decision**: **`asyncpg>=0.29`**. + +**Rationale**: +- Pure-async, no thread-shim. Faster (≈3× sync `psycopg` on small statements per published benchmarks) which directly serves SC-001 (≥10× sync RPS). +- Native cancellation: `asyncio.CancelledError` raised at any `await` point inside `asyncpg.Connection.fetch()` calls the underlying `pg_cancel_backend` flow, satisfying SC-003 directly with no extra plumbing. +- Mature pool (`asyncpg.create_pool`) with sane defaults (`min_size=10, max_size=10`) — drop-in for our existing connection-management patterns. +- Already widely used in the FastAPI ecosystem; documentation & community examples are abundant. + +**Alternatives considered**: +- **`psycopg[async]>=3.2`**: Modern, official, supports both sync + async with the same API. Rejected because (a) on small queries it benchmarks slower than `asyncpg`, (b) cancellation propagation requires `cancel_safe=True` mode and is less battle-tested, (c) we already use sync `psycopg` for the existing adapter and unifying drivers would *increase* code paths, not reduce them. +- **`aiopg`**: Older, semi-maintained shim over `psycopg2`. Rejected — effectively obsolete. +- **Wrap sync `psycopg` via `asyncio.to_thread`**: Defeats the entire purpose of `AsyncMemory` for the reference adapter. Rejected. + +**Implication for plan**: New runtime dep `asyncpg>=0.29` under `openmem[async]` extra. The async postgres adapter (`adapters/async_postgres.py`) is a fresh implementation, NOT a wrapper around `adapters/postgres.py`. Schema and SQL strings are factored into a shared `_postgres_sql.py` module that both sync and async adapters import. + +--- + +## R2 — Async HTTP client for the passthrough adapter + +**Decision**: **`httpx.AsyncClient`** (already a dep; we just use the async sibling of the sync `httpx.Client` already in use). + +**Rationale**: +- Same library, same TLS stack, same proxy handling, same auth, same timeout semantics as the existing sync passthrough — so behavioral parity is essentially free. +- Native `asyncio.CancelledError` support: cancelling a `await client.post(...)` aborts the underlying socket immediately. +- Connection pool reuse is automatic; one `AsyncClient` per `AsyncMemory` instance. + +**Alternatives considered**: +- **`aiohttp`**: Faster on large payloads but introduces a second HTTP library. Rejected — duplication risk is not justified by the marginal perf gain. +- **`urllib3.HTTPConnectionPool`**: Sync-only; would force threadpool-wrapping. Rejected. + +**Implication**: No new dep. We bump the lower bound on `httpx` to `>=0.27` for stable `AsyncClient` semantics under structured concurrency. Sync passthrough adapter unchanged. + +--- + +## R3 — Cancellation propagation contract + +**Decision**: **Three-tier cancellation contract** explicit in the `AsyncBaseAdapter` docstring: + +| Tier | Adapters | Behavior on `await` cancellation | +|---|---|---| +| **Native** | `async_postgres`, `async_passthrough` | Backend operation aborted at driver level. Connection/socket released within ≤500 ms. `asyncio.CancelledError` re-raised. | +| **Cooperative** | (future: any adapter implementing `cancel_token` protocol) | Reserved for adapters that expose an explicit cancel hook. None at v1. | +| **Best-effort** | `async_threadwrap` (mem0, supermemory, letta) | Awaiter receives `CancelledError` immediately. The worker thread completes its in-flight call and discards the result. The backend MAY have observable side-effects (e.g. mem0 row created). | + +**Rationale**: +- Honors the user's explicit ask: "propagate cancellation and abort where supported". +- The three-tier model makes it **impossible for a user to be confused** about whether their cancel actually stopped backend work — it's documented per provider. +- For threadpool-wrapped adapters, the worker thread MUST complete its call (Python has no safe way to kill a thread); we discard the result rather than leak it. + +**Alternatives considered**: +- **Propagate cancellation everywhere or nowhere**: Rejected — over-promising on threadpool-wrapped adapters would mislead users into thinking their data wasn't written when it actually was. +- **Refuse to wrap sync adapters**: Rejected — would block 3 of 5 providers from `AsyncMemory`, defeating the milestone. + +**Implementation sketch**: +- `async_postgres.py`: `await self._pool.acquire()` and `await conn.fetch(...)` are both natively cancellable; `asyncpg` handles connection release in `__aexit__`. +- `async_passthrough.py`: `httpx.AsyncClient.request()` is natively cancellable. +- `async_threadwrap.py`: `await asyncio.to_thread(self._sync.add, ...)`. When the awaiter cancels, `asyncio.to_thread` raises `CancelledError` to the awaiter while the future on the worker thread continues; we attach a no-op `add_done_callback` that logs at DEBUG when the wrapped call eventually completes after cancellation (visibility for ops). + +--- + +## R4 — Threadpool sizing for the wrapper adapter + +**Decision**: **Per-`AsyncMemory` private `ThreadPoolExecutor`** with `max_workers = min(32, (os.cpu_count() or 1) + 4)` (matches Python's stdlib `ThreadPoolExecutor` default), overridable via constructor kwarg `executor_max_workers=N`. + +**Rationale**: +- Using `asyncio.get_running_loop().run_in_executor(None, ...)` would share the global default executor across the whole process. For a server hosting multiple `AsyncMemory` instances (or a user mixing several providers), this creates non-obvious contention. +- A private pool isolates failure modes and makes `AsyncMemory.close()` deterministic (we know exactly what to shut down). +- Stdlib default is well-tuned for the I/O-bound case we have here. + +**Rejected**: A single shared pool. Rejected for isolation reasons above. Reserved as a later optimization if needed. + +--- + +## R5 — Packaging extras layout + +**Decision**: **Two opt-in extras**: + +```toml +[project.optional-dependencies] +async = ["asyncpg>=0.29", "httpx>=0.27"] +server = ["openmem[async]", "fastapi>=0.115", "uvicorn[standard]>=0.30"] +``` + +**Rationale**: +- Bare `pip install openmem` keeps the existing footprint exactly as today (FR-025, SC-007). +- `pip install openmem[async]` adds only the two async deps; no FastAPI bloat for users who just want `AsyncMemory`. +- `pip install openmem[server]` recursively pulls `[async]` since the server requires it. +- `from openmem import AsyncMemory` and `from openmem.server import app` BOTH guard the import with a clear `ImportError` listing the right `pip install` command (FR-026). + +**Rejected**: +- Bundling everything in the base install. Rejected — violates SC-007 and forces FastAPI on users who don't need it. +- Separate distribution packages (`openmem-async`, `openmem-server`). Rejected — releases would have to be co-versioned, which is more painful than extras for the same outcome. + +--- + +## R6 — How `AsyncMemory` is exposed from `openmem.__init__` + +**Decision**: **Lazy import via `__getattr__`** (PEP 562): + +```python +# openmem/__init__.py +def __getattr__(name: str): + if name == "AsyncMemory": + try: + from .async_memory import AsyncMemory + except ImportError as exc: + raise ImportError( + "AsyncMemory requires the [async] extra. " + "Install with: pip install 'openmem[async]'" + ) from exc + return AsyncMemory + raise AttributeError(f"module 'openmem' has no attribute {name!r}") +``` + +**Rationale**: +- A bare `pip install openmem` (no `asyncpg`/`httpx[async]`) MUST not crash on `import openmem`. Lazy `__getattr__` defers the import cost & failure to `from openmem import AsyncMemory`. +- Static analysis tools and `__all__` still list `AsyncMemory` so editors autocomplete it. + +**Rejected**: +- Top-level `from .async_memory import AsyncMemory` at module load. Rejected — would require `[async]` extras for *every* installation, breaking SC-007. +- Making users `from openmem.async_memory import AsyncMemory` directly. Rejected — uglier ergonomics, asymmetric with sync `Memory`. + +--- + +## R7 — FastAPI app construction & OpenAPI mounting + +**Decision**: **Hand-write FastAPI routes that mirror `spec/omp-0.1.openapi.yaml`** rather than auto-generating them via `datamodel-code-generator` or `fastapi-codegen`. + +**Rationale**: +- The spec is small (~10 routes) and stable. Codegen adds a build-time dependency, a generated-file review burden, and obscures error-mapping logic. +- We already have hand-maintained pydantic models in `openmem/types.py` that mirror the spec. Reusing them in FastAPI route signatures gives automatic request validation + OpenAPI docs at `/docs` for free. +- A separate test (`test_server_openapi_conformance.py`) validates EVERY response against the spec at runtime — this is the actual conformance gate, not codegen. + +**Rejected**: +- `fastapi-codegen` from the spec. Rejected — generated code would conflict with our existing pydantic models and add a generation step to CI. +- Mounting the entire spec as a static `openapi.json` with `app.openapi = lambda: yaml.load(...)`. Considered as an enhancement; defer to PR-B implementation. + +--- + +## R8 — How the server enforces cancellation on client disconnect + +**Decision**: **Use ASGI's `request.is_disconnected()` helper plus `anyio.create_task_group` per request** (FastAPI/Starlette idiom). + +```python +# pseudocode in routes.py +@router.get("/v1/memories/{id}") +async def get_memory(id: str, mem: AsyncMemory = Depends(get_memory_dep)): + return await mem.get(id, user_id=...) # Starlette auto-cancels this on client disconnect +``` + +Starlette already cancels the request task when the client disconnects (since 0.36+). Because our adapter calls are themselves cancellable (R3), the cancellation propagates through to `asyncpg`/`httpx` and aborts the in-flight backend operation. **No explicit `is_disconnected()` polling required** — the cancellation is structural. + +**Rationale**: Free win from using the framework correctly. Less code = fewer bugs. + +**Rejected**: Polling `is_disconnected()` on a timer. Rejected — adds latency and CPU overhead for no gain. + +--- + +## R9 — Test framework for async tests + +**Decision**: **`pytest-asyncio>=0.24` with `asyncio_mode = "auto"`** in `pyproject.toml`. + +**Rationale**: +- Auto-mode means we just write `async def test_foo(): ...` — no `@pytest.mark.asyncio` boilerplate. +- The 0.24+ release line is stable on Python 3.11+ and integrates cleanly with our existing pytest 9.x. +- `httpx.AsyncClient` is the recommended FastAPI test client (replaces deprecated `TestClient` for async route handlers); it's also already a dep via R2. + +**Rejected**: +- `anyio` test mode. Considered — would let us test trio + asyncio. Rejected — we don't support trio; sticking to asyncio simplifies the surface. +- `unittest.IsolatedAsyncioTestCase`. Rejected — the rest of our suite is pytest-native. + +--- + +## R10 — Live test gating (postgres + paid providers) + +**Decision**: **Reuse the existing M2.1 convention**: tests marked `@pytest.mark.live` auto-skip unless `OMP_LIVE=1` and the relevant `*_API_KEY` env vars are set. Async live tests reuse the same finalizer pattern that tracks created memory ids and deletes them at teardown. + +**Rationale**: One convention for both sync and async live tests means no surprise for contributors. The eval kit's env-aware factory (`_default_factory` in `runner.py`) already proves the pattern works. + +**Rejected**: A separate `OMP_ASYNC_LIVE` env. Rejected — needless duplication; if `OMP_LIVE=1` is set the async live tests should run too. + +--- + +## Summary of decisions + +| ID | Topic | Decision | +|---|---|---| +| R1 | Async postgres driver | `asyncpg>=0.29` | +| R2 | Async HTTP client | `httpx.AsyncClient` (existing dep, bump to `>=0.27`) | +| R3 | Cancellation contract | Three-tier (native / cooperative / best-effort) | +| R4 | Threadpool sizing | Per-instance `ThreadPoolExecutor`, stdlib default size | +| R5 | Packaging | Two extras: `[async]` and `[server]` | +| R6 | `AsyncMemory` exposure | Lazy `__getattr__` in `openmem.__init__` | +| R7 | Server route construction | Hand-written FastAPI routes over existing pydantic models | +| R8 | Server-side cancellation | Structural via Starlette task cancellation | +| R9 | Test framework | `pytest-asyncio>=0.24`, auto-mode | +| R10 | Live tests | Reuse `OMP_LIVE=1` + `*_API_KEY` convention | + +**No `[NEEDS CLARIFICATION]` remains.** All decisions are committed to in the plan. Implementation may revisit any decision **only** by filing a follow-up question in `tasks.md` and updating this document. diff --git a/specs/005-async-fastapi/spec.md b/specs/005-async-fastapi/spec.md new file mode 100644 index 0000000..8547b69 --- /dev/null +++ b/specs/005-async-fastapi/spec.md @@ -0,0 +1,153 @@ +# Feature Specification: M3.2 Async facade + FastAPI passthrough server + +**Feature Branch**: `005-async-fastapi` +**Created**: 2026-05-01 +**Status**: Draft +**Input**: User description: "AsyncMemory facade + FastAPI passthrough server. Keep `Memory` as canonical sync surface and add `AsyncMemory` alongside. Ship postgres + passthrough adapters as truly async first; remaining adapters (mem0/supermemory/letta) wrapped via threadpool. Combined with FastAPI passthrough server because the server is the primary consumer. Cancellation MUST propagate end-to-end and abort in-flight backend operations where supported." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — FastAPI route handler reads/writes memory without blocking the event loop (Priority: P1) + +A developer building an async web service (FastAPI / Starlette / aiohttp) wants to add OMP memory to a route handler without freezing the event loop on every call. They import `AsyncMemory`, construct it once at app startup, and `await mem.add(...)` / `await mem.search(...)` from any `async def` handler. Throughput stays bounded by network/db latency, not by Python thread contention. + +**Why this priority**: Async web frameworks are the dominant deployment shape for new Python services in 2026. Without an async facade, OMP cannot be adopted by FastAPI/Starlette users without bespoke `run_in_threadpool` wrappers that defeat the framework's concurrency model. This is the single biggest blocker to wider Python adoption. + +**Independent Test**: A FastAPI app with one `async def` route that calls `await mem.search(...)` against the postgres adapter sustains ≥500 RPS on a laptop-class machine while a sync-equivalent app sustains <50 RPS under the same load (10× improvement). Validated by an integration test with a benchmark assertion. + +**Acceptance Scenarios**: + +1. **Given** an `AsyncMemory` instance bound to the postgres adapter, **When** a caller invokes `await mem.add(content="x", user_id="alice")` from inside an `async def` function, **Then** the call completes successfully without blocking other coroutines on the same event loop. +2. **Given** the same instance, **When** the caller invokes `await asyncio.gather(*[mem.add(content=f, user_id="alice") for f in 100_facts])`, **Then** all 100 inserts complete in approximately the latency of a single insert plus pool overhead (≪ 100× sequential latency). +3. **Given** `AsyncMemory` bound to a sync-only backend (mem0/supermemory/letta), **When** a caller awaits a verb, **Then** the call still resolves successfully but is internally executed on a worker thread so the event loop is never blocked. + +--- + +### User Story 2 — Run an OMP-compliant HTTP server in front of any provider (Priority: P1) + +An operator wants to expose the same OMP verbs (`add`, `get`, `search`, `list`, `update`, `delete`) over HTTP so clients in other languages (or remote agents, or browser extensions) can use OMP without depending on the Python SDK. They run `omp-server --provider postgres --url ...` (or pass provider config via env) and the server boots a FastAPI app that mirrors `spec/omp-0.1.openapi.yaml` 1:1. + +**Why this priority**: The OpenAPI spec has been the source of truth since M1 but no reference server consumes it. A passthrough server validates that the spec is implementable end-to-end, unlocks non-Python clients, and is a prerequisite for any future TypeScript/Go/Rust SDK that wants to integration-test against a real OMP endpoint. + +**Independent Test**: Start `omp-server --provider postgres --url $OMP_POSTGRES_URL` on port 8080. From a separate process, run a curl-based smoke test that exercises every verb (add, get, search, list, update, delete) and asserts the responses validate against the OpenAPI schema. All status codes, error envelopes, and field names match the spec exactly. + +**Acceptance Scenarios**: + +1. **Given** `omp-server` running with the postgres provider, **When** a client `POST`s `{"content": "hi", "user_id": "alice"}` to `/v1/memories`, **Then** the response is `201 Created` with body matching the `Memory` schema in `omp-0.1.openapi.yaml`. +2. **Given** the same server, **When** a client requests a memory id that does not exist, **Then** the server returns `404` with an error envelope whose `code` is `not_found` (per the spec's `Error` schema). +3. **Given** the server, **When** a client sends a malformed `user_id` (empty string), **Then** the server returns `400` with `code = invalid_request` BEFORE the call reaches the underlying adapter. +4. **Given** the server is configured with any of the five providers, **When** the same OpenAPI-conformance test suite runs against it, **Then** all assertions pass with no provider-specific branches in the test code. + +--- + +### User Story 3 — Cancellation propagates and aborts in-flight backend work (Priority: P2) + +A developer's HTTP client disconnects (or the developer manually cancels an `asyncio.Task`) while a long-running `await mem.search(...)` is in flight. The `AsyncMemory` call raises `asyncio.CancelledError` at the await point, and on backends that support cancellation (postgres, passthrough HTTP) the underlying connection-level operation is actively aborted. The developer's app reclaims the connection slot immediately rather than waiting for the original call to drain. + +**Why this priority**: Without cancellation propagation, a single slow query holds a db connection or HTTP socket for its full timeout even though no caller is waiting for the result. This breaks the resource-efficiency promise of async I/O and starves the connection pool under load. Marked P2 (not P1) because basic async functionality is more urgent than abort semantics, but cancellation is required for production-grade behavior. + +**Independent Test**: Spawn an `await mem.search(...)` against a postgres adapter pointed at a slow query (artificial `pg_sleep(10)`). After 100 ms, cancel the awaiting task. Assert that (a) the cancellation raises `asyncio.CancelledError` within ≤50 ms, (b) the postgres connection is returned to the pool within ≤500 ms, and (c) the postgres server-side query is no longer running (verified via `pg_stat_activity`). + +**Acceptance Scenarios**: + +1. **Given** an in-flight `await mem.search(...)` on the postgres adapter, **When** the awaiting task is cancelled, **Then** the call raises `asyncio.CancelledError` and the underlying `asyncpg`/`psycopg` operation is cancelled at the driver level. +2. **Given** an in-flight `await mem.add(...)` on the passthrough adapter (HTTP), **When** the awaiting task is cancelled, **Then** the underlying `httpx.AsyncClient` request is aborted and the socket released. +3. **Given** an in-flight call on a threadpool-wrapped adapter (mem0/supermemory/letta), **When** the awaiting task is cancelled, **Then** the awaiter receives `CancelledError` immediately, and the worker thread completes its in-flight call in the background and discards the result (cancellation is best-effort for sync libraries; the contract is documented). + +--- + +### User Story 4 — Sync `Memory` users keep working with no changes (Priority: P1) + +An existing user of the synchronous `Memory` facade upgrades to the new release. They make zero code changes. Every script, notebook, and agent that currently imports `from openmem import Memory` continues to behave identically — same constructor signature, same return types, same error classes, same blocking call semantics. + +**Why this priority**: Compatibility is non-negotiable. Any breakage to existing `Memory` users would invalidate every M1/M2/M3.1 deployment and erode trust. This story is P1 because failing it means the entire release must be rolled back regardless of how good `AsyncMemory` is. + +**Independent Test**: Run the existing M1+M2+M3.1 test suite (currently 365 tests, 89% coverage) against the new release with no test modifications. Every passing test before the change must still pass after. + +**Acceptance Scenarios**: + +1. **Given** an existing script `from openmem import Memory; m = Memory(provider="postgres", url=...); m.add(...)`, **When** the script runs against the new release, **Then** behavior is byte-identical to the previous release for all observable outputs. +2. **Given** the existing eval kit (`openmem-eval`), **When** invoked with any provider, **Then** results are byte-identical to a run before the change (same recall, same MRR, same trace structure). + +--- + +### Edge Cases + +- **Event loop already running**: `AsyncMemory` constructor must NOT call `asyncio.run()` or any blocking I/O; it MUST be safe to instantiate inside an `async def`. +- **Reusing one `AsyncMemory` across loops**: Calling from a different event loop than the one alive at construction time MUST raise a clear error before any backend call. +- **`close()` not called**: Garbage-collecting an `AsyncMemory` without `await mem.close()` MUST NOT crash, but MUST log a warning if there are leaked connections/sockets. +- **Server backpressure**: If the FastAPI server's underlying connection pool is exhausted, the server MUST return `503 Service Unavailable` with `code = "provider_unavailable"` rather than queuing requests indefinitely. +- **Server auth**: Out of scope for v1 — the server runs unauthenticated on a private network only. A separate spec will add bearer-token auth. +- **Mixing `Memory` and `AsyncMemory` on the same backend**: Constructing both against the same postgres URL MUST work without exhausting the connection pool (each gets its own pool). +- **Cancellation during `add` that has already written to the backend**: If the row was committed before cancellation arrived, the cancellation MUST NOT roll it back — partial-write visibility matches the underlying backend's transaction semantics. The contract: cancellation is best-effort and observable side-effects may have occurred. +- **Threadpool starvation**: With sync-only adapters, more concurrent awaits than threadpool size MUST queue (not error). The default threadpool size MUST be configurable. + +## Requirements *(mandatory)* + +### Functional Requirements + +#### AsyncMemory facade + +- **FR-001**: System MUST expose a class `openmem.AsyncMemory` whose verb signatures mirror `openmem.Memory` exactly, with each verb declared `async def` and returning the same type as its sync counterpart. +- **FR-002**: `AsyncMemory.__init__` MUST accept the same `provider`, `url`, `api_key`, and adapter-specific kwargs as `Memory.__init__` and MUST NOT perform any blocking I/O. +- **FR-003**: `AsyncMemory` MUST expose `await mem.close()` to release all connections/sockets/clients held by the underlying adapter; calling `close()` more than once MUST be a no-op. +- **FR-004**: `AsyncMemory` MUST be usable as an async context manager (`async with AsyncMemory(...) as mem: ...`) which calls `close()` on exit. +- **FR-005**: For the `postgres` provider, `AsyncMemory` MUST use a real async PostgreSQL driver (no thread wrapping of sync `psycopg`). +- **FR-006**: For the `passthrough` provider, `AsyncMemory` MUST use a real async HTTP client (no thread wrapping of sync `httpx.Client`). +- **FR-007**: For the `mem0`, `supermemory`, and `letta` providers, `AsyncMemory` MUST wrap each sync verb call via a worker-thread executor so the event loop is never blocked. The threadpool size MUST be configurable per-`AsyncMemory` instance and MUST default to a value derived from `os.cpu_count()` (max 32). +- **FR-008**: `AsyncMemory` MUST emit `asyncio.CancelledError` from any awaited verb if the awaiting task is cancelled, and MUST attempt to abort the in-flight backend operation on backends that support driver-level cancellation (postgres async driver, async HTTP client). Cancellation on threadpool-wrapped backends MUST be best-effort: the awaiter sees `CancelledError` immediately while the worker thread completes its call in the background and discards the result. +- **FR-009**: `AsyncMemory` MUST raise the same error classes (`ProviderError`, `NotFoundError`, `InvalidRequestError`, `UnsupportedCapabilityError`, etc.) as `Memory` for the same conditions. +- **FR-010**: `AsyncMemory` MUST detect cross-loop misuse (constructed in loop A, awaited in loop B) and raise a clear error before any backend call. +- **FR-011**: System MUST keep the existing synchronous `openmem.Memory` class and all its current behavior unchanged. No constructor signature, return type, error class, or observable side-effect of `Memory` may change in this release. + +#### FastAPI passthrough server + +- **FR-012**: System MUST ship an executable `omp-server` console script that boots a FastAPI app exposing every verb defined in `spec/omp-0.1.openapi.yaml` at the paths declared by the spec. +- **FR-013**: `omp-server` MUST accept `--provider`, `--host`, `--port`, and provider-specific connection flags (e.g. `--url` for postgres) as CLI arguments and MUST also read the same values from the environment (`OMP_PROVIDER`, `OMP_POSTGRES_URL`, `MEM0_API_KEY`, `SUPERMEMORY_API_KEY`, `LETTA_API_KEY`). +- **FR-014**: The server MUST construct exactly one `AsyncMemory` instance at startup and reuse it for the lifetime of the process; per-request adapter construction is forbidden. +- **FR-015**: For every successful request, the response body MUST validate against the corresponding response schema in `spec/omp-0.1.openapi.yaml`. +- **FR-016**: For every error response, the body MUST be the spec's `Error` envelope (`{"error": {"code": "...", "message": "..."}}`) and the `code` MUST be one of the spec-enumerated codes (`not_found`, `invalid_request`, `provider_unavailable`, `unsupported_capability`, `ingestion_timeout`, etc.). +- **FR-017**: The server MUST translate adapter exceptions into HTTP status codes per a fixed mapping: `NotFoundError → 404`, `InvalidRequestError → 400`, `UnsupportedCapabilityError → 405`, `ProviderError(code="ingestion_timeout") → 504`, all other `ProviderError → 502`, unexpected `Exception → 500`. +- **FR-018**: The server MUST honor client disconnects: when the underlying ASGI scope reports `http.disconnect` for an in-flight request, the server MUST cancel the awaited adapter call (per FR-008 cancellation propagation). +- **FR-019**: The server MUST expose a `GET /healthz` endpoint that returns `200 OK` with `{"status": "ok"}` when the adapter is reachable and `503` with `{"error": {"code": "provider_unavailable", ...}}` otherwise. Health check MUST NOT exercise paid backend operations. +- **FR-020**: The server MUST log every request with method, path, status, latency, and a request id, but MUST NEVER log request bodies, response bodies, `user_id`, `api_key`, or any header named `Authorization`. +- **FR-021**: The server MUST reject request bodies larger than a configurable limit (default 1 MiB) with `413 Payload Too Large` BEFORE parsing, to defend against memory-exhaustion attacks. +- **FR-022**: The server MUST NOT enable CORS by default. CORS origins MUST be opt-in via `--cors-origins` / `OMP_CORS_ORIGINS` (comma-separated allowlist). +- **FR-023**: The server MUST NOT include any authentication mechanism in this release. The `omp-server --help` output and README MUST state explicitly that the server is intended for trusted-network deployment only and that auth will be added in a separate milestone. + +#### Compatibility & packaging + +- **FR-024**: `pyproject.toml` MUST declare `omp-server = "openmem.server.cli:main"` as a console script. +- **FR-025**: New dependencies (async postgres driver, async HTTP client, FastAPI, uvicorn) MUST be packaged as **extras**: `pip install openmem[async]` for `AsyncMemory`-only and `pip install openmem[server]` for the HTTP server. Bare `pip install openmem` MUST continue to install only the existing sync stack. +- **FR-026**: Importing `from openmem import AsyncMemory` MUST raise a clear `ImportError` with installation instructions if the `[async]` extra is not installed. Same for `from openmem.server import app` without `[server]`. + +### Key Entities + +- **`AsyncMemory`**: Async-native facade. Same verbs as `Memory`, returns same types, raises same errors. Owns the adapter-side resource pool (connections / sockets / threadpool). +- **`AsyncBaseAdapter`**: Internal protocol. Defines `async def add/get/search/list/update/delete/wait_for_ingest`. Each provider has either a native async implementation (postgres, passthrough) or a threadpool-wrapped sync adapter. +- **`OmpServer` (FastAPI app)**: HTTP server. Holds one `AsyncMemory`. Routes mirror the OpenAPI spec 1:1. Translates adapter exceptions into HTTP status + `Error` envelope. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A FastAPI service that calls `await mem.search(...)` against the postgres provider sustains **≥10× the throughput** (requests/second) of the equivalent sync service under identical load on the same hardware. +- **SC-002**: Running 100 concurrent `await mem.add(...)` calls against the postgres adapter completes in **under 2× the latency of a single sequential `add`** (i.e., near-perfect parallelism modulo pool size). +- **SC-003**: Cancelling an in-flight `await mem.search(...)` on the postgres or passthrough adapter releases the underlying connection/socket within **500 ms** of the cancellation. +- **SC-004**: Every existing (M1+M2+M3.1) test in the repository continues to pass without modification. Coverage gate ≥85% maintained. +- **SC-005**: An OpenAPI-conformance test suite running against `omp-server` (any provider) reports **100% of responses validate** against `spec/omp-0.1.openapi.yaml` for both success and error paths. +- **SC-006**: `omp-server` boots in **under 2 seconds** with the postgres provider on a laptop-class machine and serves the first request within **100 ms** of boot completion. +- **SC-007**: `pip install openmem` (no extras) succeeds and exposes only the existing sync surface; importing `AsyncMemory` raises `ImportError` with a clear remediation message. +- **SC-008**: A static line-count check confirms `Memory`'s public surface (constructor signature, method signatures, return types) is byte-identical to the previous release. + +## Assumptions + +- **Async postgres driver choice is implementation-detail**: The spec does not name `asyncpg` vs `psycopg[async]`. Either is acceptable provided FR-005 (real async, no threads) and FR-008 (cancellation) are met. The plan phase will pick one. +- **FastAPI is the chosen server framework**: The user's request explicitly named FastAPI. The plan may add Starlette as an underlying detail but the public footprint is FastAPI. +- **Server is unauthenticated in v1**: Trusted-network deployment only. Bearer-token / mTLS auth is a separate milestone. +- **No new OpenAPI fields**: The server consumes the existing `spec/omp-0.1.openapi.yaml` as-is. If the implementation discovers a gap in the spec, that gap is filed as a separate spec change, not bundled into this milestone. +- **Threadpool default sizing**: `min(32, (os.cpu_count() or 1) + 4)` (Python's stdlib default for `ThreadPoolExecutor`). +- **Python version**: ≥3.11 (matches existing `requires-python`). `asyncio.TaskGroup` and structured cancellation primitives are available. +- **No persistence layer in the server itself**: All state lives in the underlying provider. The server is stateless and horizontally scalable. +- **No streaming / SSE / WebSocket endpoints in v1**: The OpenAPI spec is request/response; the server matches that surface only. +- **Live tests gated by env**: Integration tests against real backends reuse the existing `OMP_LIVE=1` + per-provider `*_API_KEY` convention from M2.1. diff --git a/specs/005-async-fastapi/tasks.md b/specs/005-async-fastapi/tasks.md new file mode 100644 index 0000000..7c693c4 --- /dev/null +++ b/specs/005-async-fastapi/tasks.md @@ -0,0 +1,282 @@ +--- +description: "Tasks for M3.2 Async facade + FastAPI passthrough server" +--- + +# Tasks: M3.2 Async facade + FastAPI passthrough server + +**Input**: Design documents from `specs/005-async-fastapi/` +**Prerequisites**: [plan.md](plan.md), [spec.md](spec.md), [research.md](research.md), [data-model.md](data-model.md), [contracts/async-memory.md](contracts/async-memory.md), [contracts/http-server.md](contracts/http-server.md), [quickstart.md](quickstart.md) + +**Tests**: Contract tests are MANDATORY per the spec (FR-008 cancellation, FR-015 OpenAPI conformance) and Constitution Principle II. The contract test inventory is fixed in `contracts/async-memory.md` §7 and `contracts/http-server.md` §8. + +**Organization**: Tasks are grouped by user story. Per `plan.md`, this work ships as **two PRs on the same branch**: + +- **PR-A** = Phases 1–5 (Setup + Foundational + US1 + US3 + US4 verification) +- **PR-B** = Phase 6 (US2 — FastAPI server). Opens against PR-A's merge commit. +- **Polish** = Phase 7 (CHANGELOG, version bump, README — split per PR per the notes inside Phase 7). + +## Format: `- [ ] T### [P?] [Story?] Description` + +- **[P]** = parallelizable (different files, no incomplete dependencies). +- **[Story]** = required for user-story phases only (US1, US2, US3, US4). Setup / Foundational / Polish phases carry no story label. +- Every task names exact file paths. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Project initialization and packaging changes shared by both PRs. + +- [X] T001 Create `sdk-python/openmem/adapters/async_base.py` placeholder (just the `AsyncBaseAdapter` Protocol stub with `pass` bodies and module docstring) so subsequent imports resolve during early scaffolding +- [X] T002 Create empty package directories: `sdk-python/tests/async/__init__.py` and `sdk-python/tests/server/__init__.py` +- [X] T003 [P] Add `[project.optional-dependencies]` block to `sdk-python/pyproject.toml` with `async = ["asyncpg>=0.29", "httpx>=0.27"]` and `server = ["openmem[async]", "fastapi>=0.115", "uvicorn[standard]>=0.30"]` per research §R5 +- [X] T004 [P] Add `pytest-asyncio>=0.24` to `[project.optional-dependencies].dev` (or `[tool.uv]` dev block; whichever exists today) in `sdk-python/pyproject.toml` and configure `asyncio_mode = "auto"` under `[tool.pytest.ini_options]` per research §R9 +- [X] T005 [P] Update `.gitignore` at repo root to add `*.egg-info/` for the `[server]` extra build artifacts if not already present (verify only; add if missing) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared protocols, fixtures, and the SQL factoring that BOTH the AsyncMemory and the server depend on. **No user-story work may begin until this phase completes.** + +- [X] T006 Define the full `AsyncBaseAdapter` Protocol in `sdk-python/openmem/adapters/async_base.py` per data-model §2 (all 10 verbs as `async def` returning the same types as `BaseAdapter`; include `close()` and Sequence/Mapping imports) +- [X] T007 Extract postgres SQL strings (DDL, vector index, `add`/`get`/`search`/`list`/`update`/`delete` queries) from `sdk-python/openmem/adapters/postgres.py` into a new module `sdk-python/openmem/adapters/_postgres_sql.py` and re-import them in the existing sync adapter — verify exit code 0 (sync test suite unchanged, no behavior change) +- [X] T008 [P] Add fact-recovery + user-id validation helpers to `sdk-python/openmem/adapters/_validation.py` (extract empty-`user_id` check that exists today inside each sync adapter so both sync and async share one implementation); update sync adapters to import from here +- [X] T009 [P] Create `sdk-python/tests/async/conftest.py` with: `pytest_asyncio.fixture` factories `_make_async_memory(provider, **kw)`, async finalizer that calls `await mem.close()`, and a `live_finalizer` that tracks created ids and `await mem.delete(id)` at teardown (mirrors the M2.1 sync pattern from `tests/conftest.py`) + +**Checkpoint**: `AsyncBaseAdapter` exists; SQL is shared; async test fixtures ready. User-story phases may now proceed. + +--- + +## Phase 3: User Story 1 — Async route handlers without blocking the loop (Priority: P1) 🎯 MVP + +**Story Goal**: Developers can `await mem.add/search/...` from inside `async def` handlers (FastAPI / Starlette / aiohttp) without freezing the event loop. Concurrent fan-out via `asyncio.gather` becomes near-perfect parallelism on async-native adapters. + +**Independent Test**: `tests/async/test_async_facade.py` plus `test_async_contract_lifecycle.py` parametrized over `postgres`, `passthrough`, `mem0`, `supermemory`, `letta` all pass. Manually verifiable per [quickstart.md](quickstart.md) §1–§3. + +### Tests for User Story 1 (write FIRST, ensure they FAIL before implementation) + +- [X] T010 [P] [US1] Write `sdk-python/tests/async/test_async_facade.py` covering signature parity (§1), construction lazy-I/O (§2 C-LIFE-1), close idempotency (C-LIFE-5), use-after-close error (C-LIFE-4), context-manager usage (C-LIFE-3), and **cross-loop misuse (C-LOOP-1, FR-010): construct under loop A, await under loop B, assert clear `RuntimeError` raised before any backend call** per `contracts/async-memory.md` +- [X] T011 [P] [US1] Write `sdk-python/tests/async/test_async_contract_lifecycle.py` parametrized over all 5 providers: `add → get → update → list → delete` round-trip with return-shape equality vs sync `Memory`; **also assert `asyncio.gather` of 100 concurrent `add()` calls against postgres completes in ≤2× single-add latency (SC-002)** +- [X] T012 [P] [US1] Write `sdk-python/tests/async/test_async_contract_search.py` parametrized over all 5 providers: `search` and `context` return-shape parity, user_id scoping enforcement +- [X] T013 [P] [US1] Write `sdk-python/tests/async/test_async_contract_errors.py` parametrized over all 5 providers: every error class fires under the same conditions as sync per `contracts/async-memory.md` §5 +- [X] T014 [P] [US1] Write `sdk-python/tests/async/test_async_threadwrap.py`: executor isolation per instance, `executor.shutdown` on close, sync-state non-mutation, threadpool size override + +### Implementation for User Story 1 + +- [X] T015 [P] [US1] Implement `sdk-python/openmem/adapters/async_postgres.py` — `AsyncPostgresAdapter` using `asyncpg.create_pool`, importing SQL from `_postgres_sql.py`; lazy pool init on first verb call; supports cancellation natively (research §R1) +- [X] T016 [P] [US1] Implement `sdk-python/openmem/adapters/async_passthrough.py` — `AsyncPassthroughAdapter` using `httpx.AsyncClient`; lazy client init; honors same timeout/retry/header conventions as sync `passthrough.py` (research §R2) +- [X] T017 [P] [US1] Implement `sdk-python/openmem/adapters/async_threadwrap.py` — `AsyncThreadwrapAdapter` constructor takes a sync `BaseAdapter` instance + `ThreadPoolExecutor`; every verb calls `loop.run_in_executor(executor, partial(sync_method, *args, **kw))`; `close()` calls `executor.shutdown(wait=False, cancel_futures=True)` (data-model §2 + contracts §6) +- [X] T018 [US1] Implement `sdk-python/openmem/async_memory.py` — `AsyncMemory` class with constructor (no I/O), provider routing (postgres/passthrough → native, mem0/supermemory/letta → threadwrap), `_loop_id` cross-loop guard, `_closed` state, `__aenter__`/`__aexit__`, `close()` idempotency (data-model §1 invariants AM-INV-1..7) — depends on T015, T016, T017 +- [X] T019 [US1] Add lazy `__getattr__` to `sdk-python/openmem/__init__.py` per research §R6 — `from openmem import AsyncMemory` works iff `[async]` extra installed; raises `ImportError` with message containing exact string `pip install 'openmem[async]'` (FR-026, contracts §C-EXT-2); update `__all__` to include `"AsyncMemory"` +- [X] T020 [US1] Run the full sync test suite (`pytest sdk-python/tests -q --ignore=sdk-python/tests/async --ignore=sdk-python/tests/server`) and verify exit code 0 with the prior baseline (captured at branch-off from `main`) preserved — no tests removed, no new failures (FR-011 / SC-008 backstop) — depends on T018, T019 +- [X] T021 [US1] Run the new async test suite (`pytest sdk-python/tests/async -q`) and verify all parametrized tests pass for all 5 providers; coverage gate ≥85% maintained — depends on T020 + +**Checkpoint**: `AsyncMemory` shipped, all 5 adapters async-callable, sync `Memory` byte-identical, contract tests green. **PR-A could merge here if US3 + US4 weren't bundled.** + +--- + +## Phase 4: User Story 3 — Cancellation propagates and aborts in-flight work (Priority: P2) + +**Story Goal**: Cancelling an `await mem.` releases the underlying postgres connection / HTTP socket within 500 ms (SC-003) and aborts the server-side query on postgres. Threadwrap adapters surface `CancelledError` immediately and document best-effort semantics. + +**Independent Test**: `tests/async/test_async_cancellation.py::test_postgres_pool_release` and `::test_passthrough_socket_release` and `::test_threadwrap_immediate_cancel` all pass. + +### Tests for User Story 3 (write FIRST) + +- [X] T022 [P] [US3] Write `sdk-python/tests/async/test_async_cancellation.py::test_postgres_pool_release` per `contracts/async-memory.md` §3 C-CAN-2: spawn `mem.search(slow_query)`, cancel after 100ms, assert `pool._holders` count returns to baseline within 500ms (live-only, gated by `OMP_LIVE` + `OMP_POSTGRES_URL`) +- [X] T023 [US3] Add `::test_postgres_query_aborted` to the same file per C-CAN-3: assert `pg_stat_activity` shows no query matching the slow-sleep tag 1s after cancellation (live-only) +- [X] T024 [P] [US3] Add `::test_passthrough_socket_release` per C-CAN-2: cancel an in-flight `await mem.search(...)` against an httpx `MockTransport` whose response sleeps; assert the transport's "request started but not completed" counter increments and the awaiter sees `CancelledError` within 50ms +- [X] T025 [P] [US3] Add `::test_threadwrap_immediate_cancel` per C-CAN-4: use a controllable mock sync adapter whose `add()` blocks on a threading.Event; cancel the awaiter; assert `CancelledError` raised in ≤50ms while the worker thread keeps running; on subsequent event-set verify the orphan log line appears at DEBUG level +- [X] T026 [P] [US3] Add `::test_pool_state_after_cancel` per C-CAN-5: cancel a verb, then immediately await another verb on the same `AsyncMemory`; assert the second call succeeds (no pool corruption) + +### Implementation for User Story 3 + +- [X] T027 [US3] Audit `sdk-python/openmem/adapters/async_postgres.py` (T015) to ensure connection acquire/release uses `async with self._pool.acquire() as conn:` form so cancellation auto-releases via `__aexit__`; add a short docstring referencing C-CAN-2/C-CAN-3 +- [X] T028 [US3] Audit `sdk-python/openmem/adapters/async_passthrough.py` (T016) to confirm `httpx.AsyncClient` requests are issued without `timeout=None` and that no swallowing `try/except asyncio.CancelledError` exists; add docstring referencing C-CAN-2 +- [X] T029 [US3] In `sdk-python/openmem/adapters/async_threadwrap.py` (T017), attach an `add_done_callback` (using the `asyncio.Future` returned by `loop.run_in_executor`) that logs at `logging.DEBUG` with message `"orphan call completed after cancellation: provider=%s verb=%s"` — implements C-CAN-4 visibility (best-effort, not a hard requirement) +- [X] T030 [US3] Run `pytest sdk-python/tests/async/test_async_cancellation.py -q` (with `OMP_LIVE=1` + `OMP_POSTGRES_URL` set) and verify all cancellation tests pass — depends on T027–T029 + +**Checkpoint**: Cancellation contract enforced and tested for all three tiers. + +--- + +## Phase 5: User Story 4 — Sync `Memory` users keep working with no changes (Priority: P1) + +**Story Goal**: Existing M1+M2+M2.1+M3.1 deployments upgrade to 0.4.0 with zero code changes. `Memory`'s public surface is byte-identical. + +**Independent Test**: `pytest sdk-python/tests -q --ignore=sdk-python/tests/async --ignore=sdk-python/tests/server` reports exit code 0 with the baseline captured at branch-off (no tests removed, no new failures) without modification. + +### Tests for User Story 4 (write FIRST) + +- [X] T031 [P] [US4] Add `tests/async/test_async_facade.py::test_memory_signatures_unchanged` — store a JSON snapshot of `(name, kind, default, annotation_str)` tuples per parameter for `Memory.__init__` and every public method, committed at `tests/async/_signatures_baseline.json`; assert current introspection equals snapshot (per SC-008; JSON snapshot chosen over pickled `inspect.Signature` because the latter is not stable across Python micro versions) +- [X] T032 [P] [US4] Add `tests/async/test_packaging_extras.py::test_bare_install_imports_memory_only` — uses subprocess + `python -m venv` to create a clean venv with only the bare `openmem` install (no extras), then asserts `import openmem; from openmem import Memory` succeeds and `from openmem import AsyncMemory` raises `ImportError` whose message contains `pip install 'openmem[async]'` (FR-026, SC-007, C-EXT-1..3); skip with `pytest.skip` if the test environment cannot create a venv + +### Implementation for User Story 4 + +- [X] T033 [US4] Run baseline regression: `pytest sdk-python/tests -q --ignore=sdk-python/tests/async --ignore=sdk-python/tests/server -p no:cacheprovider` and confirm exit code 0 with the prior baseline preserved (no tests removed, no new failures); if any sync test now fails, the change to `_postgres_sql.py` (T007) or `_validation.py` (T008) must be revisited + +**Checkpoint**: PR-A end. All US1+US3+US4 tests green; existing tests unaffected. **Open PR-A → merge to `main` → checkout new branch off PR-A merge for PR-B.** + +--- + +## Phase 6: User Story 2 — Run an OMP-compliant HTTP server in front of any provider (Priority: P1) + +**Story Goal**: `omp-server --provider postgres --url ...` boots a FastAPI app that mirrors `spec/omp-0.1.openapi.yaml` 1:1. Every verb works for every provider with no provider-specific branches in the route code. + +**Independent Test**: `tests/server/test_server_openapi_conformance.py` reports 100% schema-validation pass for all routes × representative success+error cases. `omp-server --help` runs and shows the trusted-network warning. + +**Requires**: PR-A merged to `main` (this branch is rebased onto PR-A's merge commit). + +### Tests for User Story 2 (write FIRST) + +- [ ] T034 [P] [US2] Write `sdk-python/tests/server/test_server_routes.py` — for each of the 9 routes in `contracts/http-server.md` §1, issue a happy-path request via `httpx.AsyncClient(app=app)` against a FastAPI app bound to the in-memory `passthrough` provider (no real backend); assert status code, response shape, and `Content-Type: application/json`; **also assert no `Access-Control-Allow-Origin` header is returned when `cors_origins` is empty (FR-022 default-deny)** +- [ ] T035 [P] [US2] Write `sdk-python/tests/server/test_server_errors.py` parametrized over the 11-row error mapping table in `contracts/http-server.md` §3: trigger each exception via a mock adapter and assert status code + error envelope `code` +- [ ] T036 [P] [US2] Write `sdk-python/tests/server/test_server_openapi_conformance.py` — load `spec/omp-0.1.openapi.yaml` once; for ≈25 success+error cases assert response body validates against the matching `responses[].content["application/json"].schema`; assert `X-Request-Id` echoed +- [ ] T037 [P] [US2] Write `sdk-python/tests/server/test_server_health.py` — `GET /healthz` returns 200 for postgres (mocked pool) and 503 when the pool acquire times out; for mem0/supermemory/letta returns 200 unconditionally per C-HEA-4 +- [ ] T038 [P] [US2] Write `sdk-python/tests/server/test_server_logging.py` — inject a request whose body contains the literal strings `"super_secret_password"` and `"u-alice-uid"`; capture log output via pytest's `caplog`; assert neither string appears (C-LOG-2 enforcement) +- [ ] T039 [P] [US2] Write `sdk-python/tests/server/test_server_cli.py` — invoke `omp-server --help` via subprocess and assert stdout contains `"trusted-network deployment only"` and `"auth deferred"` (C-CLI-1); invoke `omp-server --version` and assert exit 0; invoke `omp-server --provider postgres` (no url, no env) and assert exit 2 with stderr starting `omp-server: missing config:` (C-CLI-3); **also `::test_boot_time` — `subprocess.Popen` `omp-server --provider passthrough --base-url http://127.0.0.1:9 --port `; measure wall-clock from spawn to first `200 OK` on `/healthz`; assert ≤2 s and the immediately-following request ≤100 ms (SC-006)** +- [ ] T040 [P] [US2] Write `sdk-python/tests/server/test_server_size_limit.py` — POST a body larger than `max_request_bytes` and assert `413` with `code = payload_too_large` (C-SIZ-1) +- [ ] T040b [P] [US2] Write `sdk-python/tests/server/test_server_disconnect.py` — issue a slow `POST /v1/memories/search` via `httpx.AsyncClient(app=app)` against a mocked async adapter that sleeps; cancel the awaiter mid-flight; assert the underlying `AsyncMemory` pool/socket count returns to baseline within 1 s (FR-018, C-DIS-3) +- [ ] T040c [P] [US2] Write `sdk-python/tests/server/test_throughput_bench.py::test_postgres_async_vs_sync` — live-only (gated by `OMP_LIVE=1` + `OMP_POSTGRES_URL`); spawn `omp-server --provider postgres` under uvicorn, drive `POST /v1/memories/search` for 30 s with 64 concurrent `httpx.AsyncClient` workers; compare against an equivalent sync-driver baseline (sync `Memory` + `ThreadPoolExecutor(64)`); assert async/sync RPS ratio ≥10× (SC-001); skip with clear message if `OMP_LIVE` unset + +### Implementation for User Story 2 + +- [ ] T041 [P] [US2] Implement `sdk-python/openmem/server/__init__.py` — re-export `app` (lazy via `__getattr__`, raising `ImportError` with `pip install 'openmem[server]'` if FastAPI missing — mirrors C-EXT-2) and `create_app(config)` +- [ ] T042 [US2] Implement `sdk-python/openmem/server/app.py` — `create_app(config: OmpServerConfig) -> FastAPI` that builds the AsyncMemory, registers as `app.state.memory`, mounts routers from `routes.py`, registers exception handlers from `errors.py`, adds startup/shutdown hooks, configures logging middleware, optionally installs CORS per C-CORS-1/2 (data-model §4) — depends on T041 +- [ ] T043 [P] [US2] Implement `sdk-python/openmem/server/errors.py` — exception → HTTP status + `Error` envelope per the 11-row mapping in `contracts/http-server.md` §3 (FR-017); register as FastAPI `exception_handler`s +- [ ] T044 [P] [US2] Implement `sdk-python/openmem/server/deps.py` — `async def get_memory(request) -> AsyncMemory` returning `request.app.state.memory`; helpers for `user_id` extraction from body or `X-User-Id` header with empty/whitespace rejection (C-UID-2) +- [ ] T045 [US2] Implement `sdk-python/openmem/server/routes.py` — one `async def` handler per route in `contracts/http-server.md` §1; each calls the corresponding `AsyncMemory` verb; uses `Depends(get_memory)` and the `user_id` helper from T044 — depends on T043, T044 +- [ ] T046 [US2] Add `LoggingMiddleware` to `sdk-python/openmem/server/app.py` (or new `middleware.py` if cleaner) implementing C-LOG-1..3: emit one INFO line per request with method/path/status/latency/request_id; never touch body — depends on T042 +- [ ] T047 [US2] Add `MaxRequestSizeMiddleware` (or use Starlette's `BaseHTTPMiddleware`) per C-SIZ-1/C-SIZ-2: rejects with 413 before Pydantic parses +- [ ] T048 [US2] Implement `sdk-python/openmem/server/cli.py` — `argparse`-based entry point per `contracts/http-server.md` §10 (CLI > env > default precedence); validates config (CFG-INV-1..4); boots uvicorn programmatically; prints `omp-server: serving at http://:` to stderr (C-CLI-4); on missing config exits 2 with stderr `omp-server: missing config: ...` +- [ ] T049 [US2] Add console script entry to `sdk-python/pyproject.toml` under `[project.scripts]`: `omp-server = "openmem.server.cli:main"` (FR-024) +- [ ] T050 [US2] Run the full server test suite (`pytest sdk-python/tests/server -q`) and verify all tests pass; coverage gate ≥85% maintained — depends on T034–T040c, T041–T049 +- [ ] T051 [US2] Live smoke test: in a separate terminal, run `omp-server --provider postgres --url $env:OMP_POSTGRES_URL --port 8080` then execute the curl smoke test from [quickstart.md](quickstart.md) §2 and verify every response matches the expected output; cleanup any created memories + +**Checkpoint**: PR-B end. `omp-server` runnable; OpenAPI conformance 100%; all 9 routes work for all 5 providers. + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**Purpose**: Documentation, version bump, CHANGELOG. Tasks split per PR. + +### Polish for PR-A (AsyncMemory) + +- [X] T052 [P] Update root [README.md](README.md) Tooling section to mention `AsyncMemory` and link to [specs/005-async-fastapi/quickstart.md](specs/005-async-fastapi/quickstart.md) §1–§4 +- [X] T053 [P] Update [sdk-python/README.md](sdk-python/README.md) with an "Async usage" subsection showing the `async with AsyncMemory(...)` pattern and the `[async]` install command +- [X] T054 Add `[0.4.0] — Unreleased (M3.2 PR-A — AsyncMemory)` section to [CHANGELOG.md](CHANGELOG.md) summarizing: new `AsyncMemory` facade, three-tier cancellation contract, `[async]` extra, threadpool wrapper for sync-only adapters, sync `Memory` byte-identical, new dep `asyncpg>=0.29` +- [X] T055 Bump `version` from `0.3.0` to `0.4.0` in `sdk-python/pyproject.toml` and update `__version__` in `sdk-python/openmem/__init__.py` if defined there +- [X] T056 Run `python -m pytest sdk-python/tests -q` (full suite) and confirm ≥85% coverage gate still passes — depends on T020, T021, T030, T033 + +### Polish for PR-B (FastAPI server) + +- [ ] T057 [P] Update root [README.md](README.md) with a new "HTTP server" section showing `pip install 'openmem[server]'` and the `omp-server --provider postgres` boot command +- [ ] T058 [P] Update [sdk-python/README.md](sdk-python/README.md) Tooling section with an `omp-server` bullet linking to the quickstart +- [ ] T059 Add `[0.5.0] — Unreleased (M3.2 PR-B — FastAPI server)` section to [CHANGELOG.md](CHANGELOG.md) summarizing: new `omp-server` console script, `[server]` extra, OpenAPI conformance test suite, `LoggingMiddleware` with sensitive-field redaction, `MaxRequestSizeMiddleware` (1 MiB default), opt-in CORS, security note that auth is deferred +- [ ] T060 Bump `version` from `0.4.0` to `0.5.0` in `sdk-python/pyproject.toml` +- [ ] T061 Run `omp-validate-spec ../omp-0.1.openapi.yaml` from `sdk-python/` to confirm the OpenAPI spec is still valid (sanity check — we did not modify it) +- [ ] T062 Update [.github/copilot-instructions.md](.github/copilot-instructions.md) to repoint at the next milestone's plan once one is selected (deferred — leave pointing at this plan for now) + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)** → no dependencies +- **Foundational (Phase 2)** → depends on Setup; **BLOCKS all user stories** +- **US1 (Phase 3)** → depends on Foundational +- **US3 (Phase 4)** → depends on US1 (audits T015–T017 from US1; cancellation tests need a working `AsyncMemory`) +- **US4 (Phase 5)** → depends on US1 (T020 already runs sync tests in US1; T031–T033 add formal compat assertions) +- **US2 (Phase 6)** → depends on US1 + US3 + US4 being merged (PR-A); the server CANNOT exist without `AsyncMemory` +- **Polish (Phase 7)** → split: T052–T056 land in PR-A; T057–T062 land in PR-B + +### PR Boundaries + +- **PR-A**: Phases 1, 2, 3, 4, 5, 7-PR-A — opens against `main`, merges with all sync tests + new async tests green +- **PR-B**: Phase 6, 7-PR-B — opens against PR-A's merge commit on `main`, merges with all server tests + OpenAPI conformance green + +### Within Each User Story + +- Tests (T010–T014, T022–T026, T031–T032, T034–T040, T040b, T040c) are written FIRST and MUST FAIL before implementation +- For US1: adapters (T015–T017) before facade (T018) before lazy import (T019); regression check (T020) and async suite run (T021) gate the story +- For US2: dependency-injection helpers (T044) and exception handlers (T043) before routes (T045); middleware (T046, T047) and CLI (T048) parallel to routes after `app.py` (T042) + +### Parallel Opportunities + +- All Setup tasks T003–T005 marked [P] can run in parallel +- T008, T009 in Foundational marked [P] (different files, no inter-deps) +- US1 tests T010–T014 all parallel (different test files) +- US1 adapter implementations T015, T016, T017 all parallel (different files) +- US3 tests T022, T024, T025, T026 parallel; T023 sequential (extends T022's file) +- US2 tests T034–T040, T040b, T040c all parallel +- US2 implementations T041, T043, T044 parallel; T042/T045/T046/T047/T048 form a dependency chain +- Polish docs T052/T053 parallel within PR-A; T057/T058 parallel within PR-B + +--- + +## Parallel Example: User Story 1 — write all contract tests in one go + +```text +T010 [P] [US1] tests/async/test_async_facade.py +T011 [P] [US1] tests/async/test_async_contract_lifecycle.py +T012 [P] [US1] tests/async/test_async_contract_search.py +T013 [P] [US1] tests/async/test_async_contract_errors.py +T014 [P] [US1] tests/async/test_async_threadwrap.py +``` + +Then implement all three async adapters in parallel: + +```text +T015 [P] [US1] adapters/async_postgres.py +T016 [P] [US1] adapters/async_passthrough.py +T017 [P] [US1] adapters/async_threadwrap.py +``` + +--- + +## Implementation Strategy + +### MVP scope (for PR-A) + +1. Phases 1–2 (Setup + Foundational) — unblocks everything +2. Phase 3 (US1) — `AsyncMemory` works for all 5 providers; sync tests still green +3. Phases 4–5 (US3 + US4) — cancellation contract enforced; compat formally asserted +4. Phase 7-PR-A (T052–T056) — docs + version bump +5. **STOP, open PR-A, merge.** This is a complete, valuable, releasable increment. + +### Incremental delivery (PR-B) + +6. Rebase a fresh branch off PR-A's merge commit +7. Phase 6 (US2) — FastAPI server +8. Phase 7-PR-B (T057–T061) — docs + version bump +9. **STOP, open PR-B, merge.** Server now consumes the `AsyncMemory` from PR-A. + +### Per-task discipline + +- Commit after each task or logical group +- Run `pytest -q` for the affected test directory after each implementation task +- The coverage gate (≥85%) is checked as part of CI on every push; do not let it drift + +--- + +## Notes + +- `[P]` = different files, no incomplete dependencies +- `[Story]` labels (US1/US2/US3/US4) only on Phase 3–6 tasks +- Setup, Foundational, and Polish phases carry no story label +- Every task names exact file paths so an LLM (or new contributor) can act on it without further context +- US4 (sync compat) is mostly **verification** rather than build — its only build is the snapshot/extras tests; the rest is the existing test suite continuing to pass +- The `omp-validate-spec` check (T061) is a smoke test only — we do NOT modify the OpenAPI spec in this milestone +- Live tests (T022, T023, T030, T040c, T051) require `OMP_LIVE=1` + `OMP_POSTGRES_URL` and are skipped otherwise + +## Extension Hooks + +**Optional Hook**: git +Command: `/speckit.git.commit` +Description: Auto-commit after task generation + +Prompt: Commit task changes? +To execute: `/speckit.git.commit`