From 48ce63937833f99de2aed76080d8fbf0d9c3264e Mon Sep 17 00:00:00 2001 From: Anthony Cueva Date: Mon, 5 Oct 2026 23:52:40 -0500 Subject: [PATCH] Add actor-bound memory tools for Python Add framework-neutral `memory_tools` and `async_memory_tools` factories for `memory_ingest` and `memory_search`. The application binds the user while model arguments exclude scope, credentials and endpoint settings. Search results are bounded and retain score, version and retrieval evidence. Errors are safe, and writes are never automatically retried. Make HTTP 202 ingest explicit through `PendingIngestError`, validate backend ID arrays, add a separate-process save/recall/correction example and prepare package version 1.1.4. The new version is not published yet. Validation: CI passes on Python 3.10, 3.11, 3.12 and 3.13. Local verification passes 622 tests and 76 tools/example tests from an installed wheel, plus Ruff, strict mypy and Vulture. Four live tests are gated; three sync/async parameter combinations are inapplicable. Local fixture correction does not prove actual engine or hosted correction behavior. Hosted persistence remains unverified. --- .github/workflows/ci.yml | 5 +- CHANGELOG.md | 9 + README.md | 64 +++++- atomicmemory/__init__.py | 21 ++ atomicmemory/_version.py | 2 +- atomicmemory/core/errors.py | 11 + .../providers/atomicmemory/async_provider.py | 4 +- atomicmemory/providers/atomicmemory/http.py | 8 +- .../providers/atomicmemory/mappers.py | 10 +- .../providers/atomicmemory/provider.py | 4 +- atomicmemory/tools.py | 164 +++++++++++++ atomicmemory/tools_models.py | 112 +++++++++ examples/memory_tools.py | 68 ++++++ pyproject.toml | 2 +- scripts/verify_tools_wheel.sh | 19 ++ tests/tools/conftest.py | 103 +++++++++ tests/tools/test_example.py | 57 +++++ tests/tools/test_memory_tools.py | 217 ++++++++++++++++++ uv.lock | 2 +- 19 files changed, 871 insertions(+), 11 deletions(-) create mode 100644 atomicmemory/tools.py create mode 100644 atomicmemory/tools_models.py create mode 100644 examples/memory_tools.py create mode 100755 scripts/verify_tools_wheel.sh create mode 100644 tests/tools/conftest.py create mode 100644 tests/tools/test_example.py create mode 100644 tests/tools/test_memory_tools.py 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" },