Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 63 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
21 changes: 21 additions & 0 deletions atomicmemory/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
InvalidScopeError,
NetworkError,
NotInitializedError,
PendingIngestError,
ProviderError,
RateLimitError,
UnsupportedOperationError,
Expand Down Expand Up @@ -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",
Expand All @@ -136,6 +147,8 @@
"AsyncAtomicMemoryClient",
"AsyncEntitiesClient",
"AsyncMemoryClient",
"AsyncMemoryTool",
"AsyncMemoryTools",
"AsyncProviderStatus",
"AsyncStorageClient",
"AtomicMemoryClient",
Expand Down Expand Up @@ -190,6 +203,11 @@
"MemoryKind",
"MemoryNamespaceConfig",
"MemoryRef",
"MemorySearchHit",
"MemorySearchOutput",
"MemoryTool",
"MemoryToolError",
"MemoryTools",
"MemoryVersion",
"MemoryVersionEvent",
"MergeEntitiesResult",
Expand All @@ -202,6 +220,7 @@
"NotInitializedError",
"PackageFormat",
"PackageRequest",
"PendingIngestError",
"PointerContentNotManagedError",
"Profile",
"Provenance",
Expand Down Expand Up @@ -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",
]
2 changes: 1 addition & 1 deletion atomicmemory/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
__version__: The current package version string (PEP 440).
"""

__version__ = "1.1.3"
__version__ = "1.1.4"
11 changes: 11 additions & 0 deletions atomicmemory/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.)."""

Expand Down
4 changes: 3 additions & 1 deletion atomicmemory/providers/atomicmemory/async_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
8 changes: 7 additions & 1 deletion atomicmemory/providers/atomicmemory/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -117,10 +117,13 @@ def fetch_json(
*,
method: str = "GET",
json: Any | None = None,
require_completed: bool = False,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply pending detection to handle ingests

When Core returns HTTP 202 through the public AtomicMemoryHandle.ingest_full or ingest_quick APIs, this opt-in defaults to false because both AtomicMemoryHandle._post_ingest and AsyncAtomicMemoryHandle._post_ingest still call fetch_json/afetch_json without require_completed=True. Those ingest paths therefore parse the pending body as a terminal result (or leak a Pydantic error) instead of raising the newly documented PendingIngestError, which can cause callers to treat a pending write as failed or completed and retry it; enable this check in both handle implementations as well.

Useful? React with 👍 / 👎.

) -> 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()


Expand Down Expand Up @@ -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()


Expand Down
10 changes: 7 additions & 3 deletions atomicmemory/providers/atomicmemory/mappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,6 +24,8 @@
SearchResult,
)

_INGEST_IDS = TypeAdapter(list[Annotated[str, Field(min_length=1)]])

_AUDIT_EVENTS: set[MemoryVersionEvent] = {"created", "updated", "superseded", "invalidated"}


Expand Down Expand Up @@ -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=[],
)

Expand Down
4 changes: 3 additions & 1 deletion atomicmemory/providers/atomicmemory/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
Loading
Loading