diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b50f3e6..80c1ba2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: run: uv run ruff format --check . - name: Mypy strict - run: uv run mypy atomicmemory --strict + run: uv run mypy atomicmemory examples/memory_tools.py --strict # `.vulture_whitelist.py` is required: it allowlists Protocol-method # parameter names and context-manager dunder args that vulture @@ -62,3 +62,6 @@ jobs: # atomicmemory-core via ATOMICMEMORY_TEST_API_URL). - name: Pytest run: uv run pytest -m 'not integration' + + - name: Verify tools from installed wheel + run: bash scripts/verify_tools_wheel.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index d2dd172..9781c6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to `atomicmemory` will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 1.1.4 (unreleased) + +- Add framework-neutral `memory_tools` and `async_memory_tools` factories with + fixed application user scope, strict argument schemas, bounded retrieval + output, and safe model-facing errors. +- Reject HTTP 202 ingest responses with `PendingIngestError` instead of returning + an empty terminal result. Pending or failed writes must not be retried blindly. +- Add a sync/async tools example and real-HTTP contract verification. + ## [1.1.3] - 2026-09-22 ### Fixed diff --git a/README.md b/README.md index 9a07069..42b1384 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ This is a Python port of the TypeScript [`atomicmemory-sdk`](https://github.com/ ## Status Stable releases are available on [PyPI](https://pypi.org/project/atomicmemory/). -This source tree prepares version `1.1.3`; consult PyPI for publication status. +This source tree prepares version `1.1.4`; consult PyPI for publication status. ## Installation @@ -85,6 +85,68 @@ async def main() -> None: asyncio.run(main()) ``` +## Agent-selected memory tools + +The following API is new in source version **1.1.4**, which is not yet published. +The published 1.1.3 package does not export these factories. For contributor +verification, use `uv sync --all-extras` in this checkout. + +```python +import os +from atomicmemory import MemoryClient, memory_tools + +with MemoryClient(providers={"atomicmemory": { + "api_url": "https://api.atomicstrata.ai", + "api_key": os.environ["ATOMICMEMORY_API_KEY"], +}}) as client: + client.initialize() + tools = memory_tools(client=client, user="authenticated-app-user") + result = tools["memory_ingest"].execute({"content": "I prefer tea."}) + page = tools["memory_search"].execute({"query": "drink preference"}) +``` + +Each descriptor has `name`, `description`, `parameters` as JSON Schema, and +`execute(arguments)`. Register those with your agent framework and serialize +results with `model_dump(mode="json", exclude_none=True)`. For async code, use +`AsyncMemoryClient`, `await client.initialize()`, `async_memory_tools`, and +`await tools[name].execute(arguments)`. + +User identity, endpoint and credentials stay in application configuration. +Arguments accept only `content` for ingest or `query` and an optional `limit` +for search. Both operations keep the configured user, and reject model-supplied +identity or transport fields. Search defaults to five hits, accepts limits up +to 20, and bounds displayed text while retaining score, version and retrieval +evidence. Backend metadata is excluded from model-facing hits. + +The tools propagate a safe `MemoryToolError` rather than raw backend errors. +Async cancellation propagates. No write is automatically retried. HTTP 202 +is pending, and the direct SDK raises `PendingIngestError`; tools fail safely. +Empty ingest arrays do not confirm a save. IDs are backend reports, not terminal +correction receipts. See [ATO-2333](https://linear.app/atomic-strata/issue/ATO-2333) +and [ATO-2334](https://linear.app/atomic-strata/issue/ATO-2334) for those contracts. + +The [single-operation example](examples/memory_tools.py) exercises separate +processes against your explicitly configured backend: + +```bash +export ATOMICMEMORY_API_KEY=your-server-key +export ATOMICMEMORY_USER=synthetic-demo-user +uv run python examples/memory_tools.py ingest 'I prefer tea.' +uv run python examples/memory_tools.py search 'drink preference' --async +uv run python examples/memory_tools.py ingest 'I now prefer coffee.' --async +uv run python examples/memory_tools.py search 'drink preference' +``` + +Use `ATOMICMEMORY_API_URL=http://localhost:17350` for a local Core with its +appropriate explicit key. A local transport fixture verifies sync/async wire +parity, process restart and synthetic correction; it does not verify hosted +persistence or actual engine correction. Hosted completion still depends on +[ATO-2425](https://linear.app/atomic-strata/issue/ATO-2425). Python requires +explicit provider configuration; it does not inherit TypeScript's zero-argument +constructor behavior. Agent-selected tools let the agent choose when to read +and write. Automatic capture/retrieval remains a separate deferred investigation +in [ATO-2420](https://linear.app/atomic-strata/issue/ATO-2420). + ## AtomicMemory-specific features When configured with the `atomicmemory` provider, the client exposes a typed handle for backend-specific routes: diff --git a/atomicmemory/__init__.py b/atomicmemory/__init__.py index 54e1703..c3bb055 100644 --- a/atomicmemory/__init__.py +++ b/atomicmemory/__init__.py @@ -20,6 +20,7 @@ InvalidScopeError, NetworkError, NotInitializedError, + PendingIngestError, ProviderError, RateLimitError, UnsupportedOperationError, @@ -124,6 +125,16 @@ VerificationResult, VerifyArtifactOptions, ) +from atomicmemory.tools import ( + AsyncMemoryTool, + AsyncMemoryTools, + MemoryTool, + MemoryToolError, + MemoryTools, + async_memory_tools, + memory_tools, +) +from atomicmemory.tools_models import MemorySearchHit, MemorySearchOutput __all__ = [ "DEFAULT_META_FACT_PATTERNS", @@ -136,6 +147,8 @@ "AsyncAtomicMemoryClient", "AsyncEntitiesClient", "AsyncMemoryClient", + "AsyncMemoryTool", + "AsyncMemoryTools", "AsyncProviderStatus", "AsyncStorageClient", "AtomicMemoryClient", @@ -190,6 +203,11 @@ "MemoryKind", "MemoryNamespaceConfig", "MemoryRef", + "MemorySearchHit", + "MemorySearchOutput", + "MemoryTool", + "MemoryToolError", + "MemoryTools", "MemoryVersion", "MemoryVersionEvent", "MergeEntitiesResult", @@ -202,6 +220,7 @@ "NotInitializedError", "PackageFormat", "PackageRequest", + "PendingIngestError", "PointerContentNotManagedError", "Profile", "Provenance", @@ -230,9 +249,11 @@ "VerificationResult", "VerifyArtifactOptions", "__version__", + "async_memory_tools", "capability_gaps", "filter_meta_facts", "is_meta_fact", + "memory_tools", "resolve_meta_fact_patterns", "satisfies_profile", ] diff --git a/atomicmemory/_version.py b/atomicmemory/_version.py index c5cd274..87f82d3 100644 --- a/atomicmemory/_version.py +++ b/atomicmemory/_version.py @@ -4,4 +4,4 @@ __version__: The current package version string (PEP 440). """ -__version__ = "1.1.3" +__version__ = "1.1.4" diff --git a/atomicmemory/core/errors.py b/atomicmemory/core/errors.py index d8998dc..11289a9 100644 --- a/atomicmemory/core/errors.py +++ b/atomicmemory/core/errors.py @@ -75,6 +75,17 @@ def __init__( self.response_body = response_body +class PendingIngestError(ProviderError): + """HTTP 202 accepted a write; no terminal ingest result is available.""" + + def __init__(self) -> None: + super().__init__( + "Ingest is pending; saving is not confirmed. Do not retry automatically.", + provider="atomicmemory", + status_code=202, + ) + + class NetworkError(AtomicMemoryError): """A transport-level failure (timeout, connection refused, DNS, etc.).""" diff --git a/atomicmemory/providers/atomicmemory/async_provider.py b/atomicmemory/providers/atomicmemory/async_provider.py index a26671e..ee91ca0 100644 --- a/atomicmemory/providers/atomicmemory/async_provider.py +++ b/atomicmemory/providers/atomicmemory/async_provider.py @@ -110,7 +110,9 @@ async def close(self) -> None: async def do_ingest(self, input: IngestInput) -> IngestResult: body = _build_ingest_body(input) path = self._route("/memories/ingest/quick" if input.mode == "verbatim" else "/memories/ingest") - raw = await afetch_json(self._require_client(), self._http_options, path, method="POST", json=body) + raw = await afetch_json( + self._require_client(), self._http_options, path, method="POST", json=body, require_completed=True + ) return to_ingest_result(raw) def _apply_meta_fact_filter(self, results: list[SearchResult]) -> list[SearchResult]: diff --git a/atomicmemory/providers/atomicmemory/http.py b/atomicmemory/providers/atomicmemory/http.py index 09b9de4..f108dc2 100644 --- a/atomicmemory/providers/atomicmemory/http.py +++ b/atomicmemory/providers/atomicmemory/http.py @@ -18,7 +18,7 @@ import httpx -from atomicmemory.core.errors import NetworkError, ProviderError, RateLimitError +from atomicmemory.core.errors import NetworkError, PendingIngestError, ProviderError, RateLimitError _PROVIDER_NAME = "atomicmemory" @@ -117,10 +117,13 @@ def fetch_json( *, method: str = "GET", json: Any | None = None, + require_completed: bool = False, ) -> Any: """Send a request and return the decoded JSON response body.""" response = _request(client, options, method, path, json=json) _raise_for_status(response, path) + if require_completed and response.status_code == 202: + raise PendingIngestError() return response.json() @@ -211,9 +214,12 @@ async def afetch_json( *, method: str = "GET", json: Any | None = None, + require_completed: bool = False, ) -> Any: response = await _arequest(client, options, method, path, json=json) _raise_for_status(response, path) + if require_completed and response.status_code == 202: + raise PendingIngestError() return response.json() diff --git a/atomicmemory/providers/atomicmemory/mappers.py b/atomicmemory/providers/atomicmemory/mappers.py index 1b3a353..ed62e96 100644 --- a/atomicmemory/providers/atomicmemory/mappers.py +++ b/atomicmemory/providers/atomicmemory/mappers.py @@ -9,7 +9,9 @@ from __future__ import annotations from datetime import datetime, timezone -from typing import Any +from typing import Annotated, Any + +from pydantic import Field, TypeAdapter from atomicmemory.memory.types import ( IngestResult, @@ -22,6 +24,8 @@ SearchResult, ) +_INGEST_IDS = TypeAdapter(list[Annotated[str, Field(min_length=1)]]) + _AUDIT_EVENTS: set[MemoryVersionEvent] = {"created", "updated", "superseded", "invalidated"} @@ -132,8 +136,8 @@ def to_retrieval_receipt(raw: dict[str, Any]) -> RetrievalReceipt: def to_ingest_result(raw: dict[str, Any]) -> IngestResult: """Map ``POST /memories/ingest[/quick]`` response to V3 IngestResult.""" return IngestResult( - created=list(raw.get("stored_memory_ids") or []), - updated=list(raw.get("updated_memory_ids") or []), + created=_INGEST_IDS.validate_python(raw.get("stored_memory_ids", []), strict=True), + updated=_INGEST_IDS.validate_python(raw.get("updated_memory_ids", []), strict=True), unchanged=[], ) diff --git a/atomicmemory/providers/atomicmemory/provider.py b/atomicmemory/providers/atomicmemory/provider.py index aeab9b3..396138b 100644 --- a/atomicmemory/providers/atomicmemory/provider.py +++ b/atomicmemory/providers/atomicmemory/provider.py @@ -102,7 +102,9 @@ def close(self) -> None: def do_ingest(self, input: IngestInput) -> IngestResult: body = _build_ingest_body(input) path = self._route("/memories/ingest/quick" if input.mode == "verbatim" else "/memories/ingest") - raw = fetch_json(self._require_client(), self._http_options, path, method="POST", json=body) + raw = fetch_json( + self._require_client(), self._http_options, path, method="POST", json=body, require_completed=True + ) return to_ingest_result(raw) def _apply_meta_fact_filter(self, results: list[SearchResult]) -> list[SearchResult]: diff --git a/atomicmemory/tools.py b/atomicmemory/tools.py new file mode 100644 index 0000000..a3f9a86 --- /dev/null +++ b/atomicmemory/tools.py @@ -0,0 +1,164 @@ +"""Framework-neutral ingest/search tools with an application-owned user scope.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Generic, TypedDict, TypeVar + +from pydantic import BaseModel + +from atomicmemory.client.async_memory_client import AsyncMemoryClient +from atomicmemory.client.memory_client import MemoryClient +from atomicmemory.core.errors import AtomicMemoryError, ConfigError +from atomicmemory.memory.types import IngestResult, Scope, SearchRequest, TextIngest +from atomicmemory.tools_models import IngestArguments, MemorySearchOutput, SearchArguments, ToolScope, project_search + +_Result = TypeVar("_Result", bound=BaseModel) +_Arguments = TypeVar("_Arguments", bound=BaseModel) + + +class MemoryToolError(AtomicMemoryError): + """Safe model-facing failure. A failed write may still have persisted.""" + + +@dataclass(frozen=True) +class MemoryTool(Generic[_Result]): + """Sync tool descriptor. Pass decoded JSON arguments to ``execute``. + + Attributes: + name: Stable tool name for framework registration. + description: Model-facing purpose. + parameters: JSON Schema for model arguments; identity is excluded. + execute: Validates arguments and calls the initialized client. + """ + + name: str + description: str + parameters: dict[str, object] + execute: Callable[[dict[str, object]], _Result] + + +@dataclass(frozen=True) +class AsyncMemoryTool(Generic[_Result]): + """Async tool descriptor with the same schemas and outputs as ``MemoryTool``.""" + + name: str + description: str + parameters: dict[str, object] + execute: Callable[[dict[str, object]], Awaitable[_Result]] + + +class MemoryTools(TypedDict): + """Both sync tools, with operation-specific result types.""" + + memory_ingest: MemoryTool[IngestResult] + memory_search: MemoryTool[MemorySearchOutput] + + +class AsyncMemoryTools(TypedDict): + """Both async tools, with operation-specific result types.""" + + memory_ingest: AsyncMemoryTool[IngestResult] + memory_search: AsyncMemoryTool[MemorySearchOutput] + + +def _execute(schema: type[_Arguments], arguments: dict[str, object], run: Callable[[_Arguments], _Result]) -> _Result: + try: + return run(schema.model_validate(arguments)) + except Exception: + raise MemoryToolError("Memory tool failed.") from None + + +async def _execute_async( + schema: type[_Arguments], arguments: dict[str, object], run: Callable[[_Arguments], Awaitable[_Result]] +) -> _Result: + try: + return await run(schema.model_validate(arguments)) + except Exception: + raise MemoryToolError("Memory tool failed.") from None + + +def _user_scope(user: str) -> str: + try: + return ToolScope(user=user).user + except Exception: + raise ConfigError("Memory tools require a valid application-owned user.") from None + + +def memory_tools(*, client: MemoryClient, user: str) -> MemoryTools: + """Return two sync tools for one fixed user. Initialize the client first. + + Args: + client: Application-owned initialized memory client. + user: Authenticated application user; never supplied by the model. + + Returns: + ``memory_ingest`` and ``memory_search`` descriptors. Serialize results + with ``model_dump(mode="json", exclude_none=True)`` for your framework. + + Raises: + ConfigError: The user is blank, too long, or a project identifier. + """ + bound_user = _user_scope(user) + + def ingest(parsed: IngestArguments) -> IngestResult: + return client.ingest(TextIngest(content=parsed.content, scope=Scope(user=bound_user))) + + def search(parsed: SearchArguments) -> MemorySearchOutput: + page = client.search(SearchRequest(query=parsed.query, limit=parsed.limit, scope=Scope(user=bound_user))) + return project_search(page, parsed.limit) + + return { + "memory_ingest": MemoryTool( + "memory_ingest", + "Store a fact in memory. Ingest IDs are backend reports, not correction guarantees.", + IngestArguments.model_json_schema(), + lambda args: _execute(IngestArguments, args, ingest), + ), + "memory_search": MemoryTool( + "memory_search", + "Search saved facts for the current user. Scores are provider-specific.", + SearchArguments.model_json_schema(), + lambda args: _execute(SearchArguments, args, search), + ), + } + + +def async_memory_tools(*, client: AsyncMemoryClient, user: str) -> AsyncMemoryTools: + """Return async tools for one fixed user after ``await client.initialize()``. + + Args: + client: Application-owned initialized async memory client. + user: Authenticated application user; never supplied by the model. + + Returns: + The same descriptors as ``memory_tools``, with awaitable execution. + Cancellation propagates without an automatic retry or rollback claim. + + Raises: + ConfigError: The user is blank, too long, or a project identifier. + """ + bound_user = _user_scope(user) + + async def ingest(parsed: IngestArguments) -> IngestResult: + return await client.ingest(TextIngest(content=parsed.content, scope=Scope(user=bound_user))) + + async def search(parsed: SearchArguments) -> MemorySearchOutput: + page = await client.search(SearchRequest(query=parsed.query, limit=parsed.limit, scope=Scope(user=bound_user))) + return project_search(page, parsed.limit) + + return { + "memory_ingest": AsyncMemoryTool( + "memory_ingest", + "Store a fact in memory. Ingest IDs are backend reports, not correction guarantees.", + IngestArguments.model_json_schema(), + lambda args: _execute_async(IngestArguments, args, ingest), + ), + "memory_search": AsyncMemoryTool( + "memory_search", + "Search saved facts for the current user. Scores are provider-specific.", + SearchArguments.model_json_schema(), + lambda args: _execute_async(SearchArguments, args, search), + ), + } diff --git a/atomicmemory/tools_models.py b/atomicmemory/tools_models.py new file mode 100644 index 0000000..937f28e --- /dev/null +++ b/atomicmemory/tools_models.py @@ -0,0 +1,112 @@ +"""Validated tool arguments and bounded, allowlisted retrieval output.""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from atomicmemory.memory.types import RetrievalReceipt, SearchResultPage + +MAX_USER_LENGTH = 256 +MAX_QUERY_LENGTH = 4096 +MAX_CONTENT_LENGTH = 32768 +MAX_SEARCH_LIMIT = 20 +MAX_HIT_CONTENT = 4096 +MAX_TOTAL_CONTENT = 16384 + + +class ToolScope(BaseModel): + """Trusted configuration, excluded from the model's argument schemas.""" + + model_config = ConfigDict(extra="forbid", strict=True) + user: str = Field(min_length=1, max_length=MAX_USER_LENGTH) + + @field_validator("user") + @classmethod + def validate_user(cls, value: str) -> str: + """Refuse blank users and project-wide Cloud identities.""" + if not value.strip() or value.strip().startswith("project:"): + raise ValueError("Invalid user") + return value + + +class IngestArguments(BaseModel): + """Model-controlled ingest arguments; no endpoint, identity, or metadata.""" + + model_config = ConfigDict(extra="forbid", strict=True) + content: str = Field(min_length=1, max_length=MAX_CONTENT_LENGTH) + + @field_validator("content") + @classmethod + def nonblank(cls, value: str) -> str: + """Refuse whitespace-only input without changing saved text.""" + if not value.strip(): + raise ValueError("Blank content") + return value + + +class SearchArguments(BaseModel): + """Model-controlled search arguments with the TypeScript tool limits.""" + + model_config = ConfigDict(extra="forbid", strict=True) + query: str = Field(min_length=1, max_length=MAX_QUERY_LENGTH) + limit: int = Field(default=5, ge=1, le=MAX_SEARCH_LIMIT) + + @field_validator("query") + @classmethod + def nonblank(cls, value: str) -> str: + """Refuse whitespace-only queries.""" + if not value.strip(): + raise ValueError("Blank query") + return value + + +class MemorySearchHit(BaseModel): + """Search evidence without backend metadata, provenance, or scope fields.""" + + model_config = ConfigDict(extra="forbid", strict=True, allow_inf_nan=False) + id: str = Field(min_length=1) + content: str + score: float + truncated: bool + similarity: float | None = None + ranking_score: float | None = None + relevance: float | None = None + version_id: str | None = None + observed_at: str | None = None + + +class MemorySearchOutput(BaseModel): + """Bounded hits and the backend's original retrieval evidence.""" + + results: list[MemorySearchHit] + truncated: bool + retrieval: RetrievalReceipt | None = None + cursor: str | None = None + + +def project_search(page: SearchResultPage, limit: int) -> MemorySearchOutput: + """Bound model-facing hit count and text while retaining score/version evidence.""" + remaining = MAX_TOTAL_CONTENT + results: list[MemorySearchHit] = [] + for hit in page.results[:limit]: + content = hit.memory.content[: min(MAX_HIT_CONTENT, remaining)] + remaining -= len(content) + results.append( + MemorySearchHit( + id=hit.memory.id, + content=content, + score=hit.score, + truncated=len(content) < len(hit.memory.content), + similarity=hit.similarity, + ranking_score=hit.ranking_score, + relevance=hit.relevance, + version_id=hit.version_id, + observed_at=hit.observed_at, + ) + ) + return MemorySearchOutput( + results=results, + truncated=len(page.results) > len(results) or any(hit.truncated for hit in results), + retrieval=page.retrieval, + cursor=page.cursor, + ) diff --git a/examples/memory_tools.py b/examples/memory_tools.py new file mode 100644 index 0000000..168a9f1 --- /dev/null +++ b/examples/memory_tools.py @@ -0,0 +1,68 @@ +"""Run one operation per process to check persistent memory with sync or async tools. + +Set ATOMICMEMORY_API_KEY and ATOMICMEMORY_USER in the application environment. +ATOMICMEMORY_API_URL defaults to Cloud; local endpoints use explicit configuration. +This example does not call a model, retry writes, or claim terminal correction receipts. +""" + +from __future__ import annotations + +import argparse +import asyncio +import os + +from atomicmemory import AsyncMemoryClient, MemoryClient, async_memory_tools, memory_tools + + +def arguments() -> argparse.Namespace: + """Read application configuration and the selected single operation.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("operation", choices=["ingest", "search"]) + parser.add_argument("text") + parser.add_argument("--async", dest="use_async", action="store_true") + parsed = parser.parse_args() + parsed.user = os.environ["ATOMICMEMORY_USER"] + parsed.providers = { + "atomicmemory": { + "api_url": os.environ.get("ATOMICMEMORY_API_URL", "https://api.atomicstrata.ai"), + "api_key": os.environ["ATOMICMEMORY_API_KEY"], + } + } + return parsed + + +def run_sync(args: argparse.Namespace) -> None: + """Initialize once and invoke one sync tool with model-shaped arguments.""" + with MemoryClient(providers=args.providers) as client: + client.initialize() + tools = memory_tools(client=client, user=args.user) + if args.operation == "ingest": + result = tools["memory_ingest"].execute({"content": args.text}) + if not (result.created or result.updated or result.unchanged): + raise RuntimeError("Saving was not confirmed; do not retry automatically.") + print(result.model_dump_json(exclude_none=True)) + else: + print(tools["memory_search"].execute({"query": args.text}).model_dump_json(exclude_none=True)) + + +async def run_async(args: argparse.Namespace) -> None: + """Initialize once and invoke the equivalent async tool.""" + async with AsyncMemoryClient(providers=args.providers) as client: + await client.initialize() + tools = async_memory_tools(client=client, user=args.user) + if args.operation == "ingest": + result = await tools["memory_ingest"].execute({"content": args.text}) + if not (result.created or result.updated or result.unchanged): + raise RuntimeError("Saving was not confirmed; do not retry automatically.") + print(result.model_dump_json(exclude_none=True)) + else: + result_page = await tools["memory_search"].execute({"query": args.text}) + print(result_page.model_dump_json(exclude_none=True)) + + +if __name__ == "__main__": + args = arguments() + if args.use_async: + asyncio.run(run_async(args)) + else: + run_sync(args) diff --git a/pyproject.toml b/pyproject.toml index a5fc8cf..e378da2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "atomicmemory" -version = "1.1.3" +version = "1.1.4" description = "Python client SDK for AtomicMemory memory and artifact storage." readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/verify_tools_wheel.sh b/scripts/verify_tools_wheel.sh new file mode 100755 index 0000000..7716d6e --- /dev/null +++ b/scripts/verify_tools_wheel.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Verify public tools and process-restart examples from an installed wheel. +set -euo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +expected_version=$(uv run --directory "$root" python -c 'import atomicmemory; print(atomicmemory.__version__)') +consumer=$(mktemp -d) +trap 'rm -rf "$consumer"' EXIT +uv build --wheel --out-dir "$consumer/dist" "$root" +cp -R "$root/tests/tools" "$consumer/tools" +mkdir -p "$consumer/tests" "$consumer/examples" +mv "$consumer/tools" "$consumer/tests/tools" +cp "$root/tests/__init__.py" "$consumer/tests/__init__.py" +cp "$root/examples/memory_tools.py" "$consumer/examples/memory_tools.py" +uv init --bare --no-workspace "$consumer" +cd "$consumer" +uv add "$consumer"/dist/*.whl +uv add --dev pytest pytest-asyncio respx +uv run python -c 'import atomicmemory; from pathlib import Path; assert "site-packages" in str(Path(atomicmemory.__file__)); import sys; assert atomicmemory.__version__ == sys.argv[1]; assert all(hasattr(atomicmemory, name) for name in atomicmemory.__all__)' "$expected_version" +uv run python -m pytest tests/tools --asyncio-mode=auto -q diff --git a/tests/tools/conftest.py b/tests/tools/conftest.py new file mode 100644 index 0000000..940003d --- /dev/null +++ b/tests/tools/conftest.py @@ -0,0 +1,103 @@ +"""Real HTTP contract fixture for sync/async public memory tools.""" + +from __future__ import annotations + +import json +import threading +from collections.abc import Iterator +from dataclasses import dataclass, field +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + +import pytest + + +@dataclass +class Backend: + """Synthetic fixture state, independent of SDK and tool implementation.""" + + url: str = "" + requests: list[dict[str, Any]] = field(default_factory=list) + facts: dict[str, str] = field(default_factory=dict) + status: int = 200 + ingest_response: dict[str, Any] | None = None + hits: list[dict[str, Any]] | None = None + + +@pytest.fixture +def backend() -> Iterator[Backend]: + state = Backend() + + class Handler(BaseHTTPRequestHandler): + def log_message(self, format: str, *args: object) -> None: + """Keep keys and synthetic payloads out of console output.""" + + def do_POST(self) -> None: + body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + state.requests.append({"path": self.path, "body": body, "auth": self.headers.get("Authorization")}) + status, result = respond(state, self.path, body) + payload = json.dumps(result).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + state.url = f"http://127.0.0.1:{server.server_port}" + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield state + finally: + server.shutdown() + server.server_close() + thread.join() + + +def respond(state: Backend, path: str, body: dict[str, Any]) -> tuple[int, dict[str, Any]]: + """Implement only the v1 extraction and fast-search wire contract.""" + if state.status != 200: + return state.status, {"error": "private-backend-detail", "api_key": "test-secret"} + user = body["user_id"] + if path == "/v1/memories/ingest": + if state.ingest_response is not None: + return 201, state.ingest_response + correction = user in state.facts + state.facts[user] = body["conversation"] + return 201, { + "stored_memory_ids": [] if correction else ["mem-1"], + "updated_memory_ids": ["mem-1"] if correction else [], + } + if path == "/v1/memories/search/fast": + hits = state.hits + if hits is None: + hits = ( + [] + if user not in state.facts + else [ + { + "id": "mem-1", + "content": state.facts[user], + "ranking_score": 0.9, + "semantic_similarity": 0.8, + "relevance": 0.7, + "version_id": "v-1", + "observed_at": "2026-10-05T00:00:00Z", + "metadata": {"secret": "private"}, + } + ] + ) + return 200, { + "memories": hits, + "count": len(hits), + "retrieval": { + "embedding_model": "fixture", + "embedding_model_version": "1", + "embedding_dimensions": 2, + "query_text": body["query"], + "candidate_ids": [h["id"] for h in hits], + "trace_id": "trace-1", + }, + } + return 404, {"error": "unknown fixture route"} diff --git a/tests/tools/test_example.py b/tests/tools/test_example.py new file mode 100644 index 0000000..8a0575f --- /dev/null +++ b/tests/tools/test_example.py @@ -0,0 +1,57 @@ +"""Run the copied public example in separate processes against the HTTP fixture.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from tests.tools.conftest import Backend + +EXAMPLE = Path(__file__).resolve().parents[2] / "examples" / "memory_tools.py" + + +def run_example(backend: Backend, operation: str, text: str, use_async: bool) -> subprocess.CompletedProcess[str]: + env = { + **os.environ, + "ATOMICMEMORY_API_URL": backend.url, + "ATOMICMEMORY_API_KEY": "test-secret", + "ATOMICMEMORY_USER": "example-user", + } + command = [sys.executable, str(EXAMPLE), operation, text] + if use_async: + command.append("--async") + return subprocess.run(command, env=env, text=True, capture_output=True, timeout=30, check=False) + + +@pytest.mark.parametrize("use_async", [False, True], ids=["sync", "async"]) +def test_example_persists_across_processes_and_shows_correction(backend, use_async): + saved = run_example(backend, "ingest", "I prefer tea.", use_async) + assert saved.returncode == 0, saved.stderr + assert json.loads(saved.stdout)["created"] == ["mem-1"] + recalled = run_example(backend, "search", "drink", use_async) + assert recalled.returncode == 0, recalled.stderr + assert json.loads(recalled.stdout)["results"][0]["content"] == "I prefer tea." + corrected = run_example(backend, "ingest", "I now prefer coffee.", use_async) + assert corrected.returncode == 0, corrected.stderr + assert json.loads(corrected.stdout)["updated"] == ["mem-1"] + final = run_example(backend, "search", "drink", use_async) + assert final.returncode == 0, final.stderr + assert json.loads(final.stdout)["results"][0]["content"] == "I now prefer coffee." + + +@pytest.mark.parametrize("status", [202, 401, 503]) +@pytest.mark.parametrize("use_async", [False, True]) +def test_example_exits_without_retry_or_leaking_backend_errors(backend, use_async, status): + backend.status = status + result = run_example(backend, "ingest", "Synthetic fact", use_async) + assert result.returncode != 0 + assert result.stdout == "" + assert "Memory tool failed." in result.stderr + assert "test-secret" not in result.stderr + assert "private-backend-detail" not in result.stderr + assert len(backend.requests) == 1 diff --git a/tests/tools/test_memory_tools.py b/tests/tools/test_memory_tools.py new file mode 100644 index 0000000..0b411bb --- /dev/null +++ b/tests/tools/test_memory_tools.py @@ -0,0 +1,217 @@ +"""Drive both public factories through real HTTP clients and a v1 contract server.""" + +from __future__ import annotations + +import asyncio +import inspect +import json + +import httpx +import pytest +import respx + +from atomicmemory import ( + AsyncMemoryClient, + ConfigError, + MemoryClient, + MemoryToolError, + PendingIngestError, + Scope, + TextIngest, + async_memory_tools, + memory_tools, +) +from tests.tools.conftest import Backend + + +async def execute(tools, name, arguments): + result = tools[name].execute(arguments) + if inspect.isawaitable(result): + result = await result + return result.model_dump(mode="json", exclude_none=True) + + +@pytest.fixture(params=[False, True], ids=["sync", "async"]) +async def configured(request, backend): + config = {"atomicmemory": {"api_url": backend.url, "api_key": "test-secret"}} + client = AsyncMemoryClient(config) if request.param else MemoryClient(config) + factory = async_memory_tools if request.param else memory_tools + if request.param: + await client.initialize() + else: + client.initialize() + try: + yield client, factory + finally: + result = client.close() + if inspect.isawaitable(result): + await result + + +async def test_save_restart_recall_correction_and_actor_isolation(configured, backend): + client, factory = configured + tools = factory(client=client, user="alice") + assert await execute(tools, "memory_ingest", {"content": "I prefer tea."}) == { + "created": ["mem-1"], + "updated": [], + "unchanged": [], + } + # Reopen the actual client; no conversation state is kept in the tool factory. + closed = client.close() + if inspect.isawaitable(closed): + await closed + initialized = client.initialize() + if inspect.isawaitable(initialized): + await initialized + recalled = await execute(factory(client=client, user="alice"), "memory_search", {"query": "drink"}) + assert recalled["results"][0]["content"] == "I prefer tea." + assert recalled["results"][0]["version_id"] == "v-1" + assert recalled["retrieval"]["trace_id"] == "trace-1" + assert "metadata" not in recalled["results"][0] + assert await execute(tools, "memory_ingest", {"content": "I now prefer coffee."}) == { + "created": [], + "updated": ["mem-1"], + "unchanged": [], + } + assert (await execute(tools, "memory_search", {"query": "drink"}))["results"][0][ + "content" + ] == "I now prefer coffee." + assert (await execute(factory(client=client, user="bob"), "memory_search", {"query": "drink"}))["results"] == [] + assert all(r["auth"] == "Bearer test-secret" for r in backend.requests) + assert backend.requests[0]["body"] == { + "user_id": "alice", + "conversation": "I prefer tea.", + "source_site": "sdk", + "source_url": "", + } + + +@pytest.mark.parametrize( + "name,args", + [ + ("memory_ingest", {"content": "fact", "user": "bob"}), + ("memory_search", {"query": "fact", "scope": {"user": "bob"}}), + ("memory_ingest", {"content": " "}), + ("memory_search", {"query": " "}), + ("memory_search", {"query": "fact", "limit": True}), + ("memory_search", {"query": "fact", "limit": 21}), + ("memory_search", {"query": "fact", "limit": "5"}), + ("memory_ingest", {"content": "a" * 32769}), + ("memory_search", {"query": "a" * 4097}), + ], +) +async def test_invalid_arguments_never_reach_http(configured, backend, name, args): + client, factory = configured + with pytest.raises(MemoryToolError, match=r"^Memory tool failed\.$"): + await execute(factory(client=client, user="alice"), name, args) + assert backend.requests == [] + + +@pytest.mark.parametrize("user", ["", " ", "project:example", " project:example", "a" * 257]) +def test_invalid_trusted_user(user): + for client, factory in [(MemoryClient, memory_tools), (AsyncMemoryClient, async_memory_tools)]: + with pytest.raises(ConfigError, match="application-owned user"): + factory(client=client({"atomicmemory": {"api_url": "http://localhost:17350"}}), user=user) + + +@pytest.mark.parametrize("status", [202, 401, 422, 429, 500, 503]) +@pytest.mark.parametrize("name,args", [("memory_ingest", {"content": "fact"}), ("memory_search", {"query": "fact"})]) +async def test_http_failures_are_safe_and_not_retried(configured, backend, status, name, args): + if status == 202 and name == "memory_search": + pytest.skip("202 is an ingest-specific protocol response") + client, factory = configured + backend.status = status + with pytest.raises(MemoryToolError) as caught: + await execute(factory(client=client, user="alice"), name, args) + assert str(caught.value) == "Memory tool failed." + assert "test-secret" not in repr(caught.value) + assert "private-backend-detail" not in repr(caught.value) + assert len(backend.requests) == 1 + + +async def test_sdk_pending_ingest_is_explicit(configured, backend): + client, _ = configured + backend.status = 202 + with pytest.raises(PendingIngestError): + result = client.ingest(TextIngest(content="fact", scope=Scope(user="alice"))) + if inspect.isawaitable(result): + await result + assert len(backend.requests) == 1 + + +async def test_search_is_bounded_and_preserves_evidence(configured, backend: Backend): + client, factory = configured + backend.hits = [{"id": f"m-{n}", "content": "🙂" * 5000, "ranking_score": 0.0} for n in range(21)] + output = await execute(factory(client=client, user="alice"), "memory_search", {"query": "fact", "limit": 20}) + assert len(output["results"]) == 20 + assert sum(len(hit["content"]) for hit in output["results"]) == 16384 + assert output["results"][0]["score"] == 0.0 + assert output["truncated"] is True + assert output["retrieval"]["candidate_ids"] == [f"m-{n}" for n in range(21)] + + +async def test_default_schema_limits_and_no_identity(configured): + client, factory = configured + tools = factory(client=client, user="alice") + assert set(tools) == {"memory_ingest", "memory_search"} + assert set(tools["memory_ingest"].parameters["properties"]) == {"content"} + assert set(tools["memory_search"].parameters["properties"]) == {"query", "limit"} + assert tools["memory_search"].parameters["properties"]["limit"]["default"] == 5 + assert tools["memory_search"].parameters["additionalProperties"] is False + assert "alice" not in json.dumps(tools["memory_search"].parameters) + + +@pytest.mark.parametrize("error", [httpx.ReadTimeout("test-secret"), httpx.ConnectError("test-secret")]) +async def test_transport_errors_are_safe(configured, backend, error): + client, factory = configured + with respx.mock as router: + route = router.post(f"{backend.url}/v1/memories/ingest").mock(side_effect=error) + with pytest.raises(MemoryToolError, match=r"^Memory tool failed\.$"): + await execute(factory(client=client, user="alice"), "memory_ingest", {"content": "fact"}) + assert route.call_count == 1 + + +async def test_async_cancellation_propagates(configured, backend): + client, factory = configured + if not isinstance(client, AsyncMemoryClient): + pytest.skip("sync tools do not expose async cancellation") + + attempts = [] + + async def cancel(request): + attempts.append(request.url.path) + raise asyncio.CancelledError() + + with respx.mock as router: + router.post(f"{backend.url}/v1/memories/ingest").mock(side_effect=cancel) + with pytest.raises(asyncio.CancelledError): + await execute(factory(client=client, user="alice"), "memory_ingest", {"content": "fact"}) + assert attempts == ["/v1/memories/ingest"] + + +@pytest.mark.parametrize( + "response", + [ + {"stored_memory_ids": "not-an-array"}, + {"stored_memory_ids": [""]}, + {"stored_memory_ids": [1]}, + {"updated_memory_ids": False}, + ], +) +async def test_malformed_receipts_fail_safely(configured, backend, response): + client, factory = configured + backend.ingest_response = response + with pytest.raises(MemoryToolError, match=r"^Memory tool failed\.$"): + await execute(factory(client=client, user="alice"), "memory_ingest", {"content": "fact"}) + assert len(backend.requests) == 1 + + +async def test_empty_ingest_arrays_are_not_invented_success(configured, backend): + client, factory = configured + backend.ingest_response = {"stored_memory_ids": [], "updated_memory_ids": []} + assert await execute(factory(client=client, user="alice"), "memory_ingest", {"content": "fact"}) == { + "created": [], + "updated": [], + "unchanged": [], + } + assert len(backend.requests) == 1 diff --git a/uv.lock b/uv.lock index 4bfaaf6..9fcdc22 100644 --- a/uv.lock +++ b/uv.lock @@ -92,7 +92,7 @@ wheels = [ [[package]] name = "atomicmemory" -version = "1.1.3" +version = "1.1.4" source = { editable = "." } dependencies = [ { name = "httpx" },