Skip to content

Commit 65bc2e5

Browse files
authored
Add actor-bound memory tools for Python (#8)
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.
1 parent a61c1fe commit 65bc2e5

19 files changed

Lines changed: 871 additions & 11 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ jobs:
5050
run: uv run ruff format --check .
5151

5252
- name: Mypy strict
53-
run: uv run mypy atomicmemory --strict
53+
run: uv run mypy atomicmemory examples/memory_tools.py --strict
5454

5555
# `.vulture_whitelist.py` is required: it allowlists Protocol-method
5656
# parameter names and context-manager dunder args that vulture
@@ -62,3 +62,6 @@ jobs:
6262
# atomicmemory-core via ATOMICMEMORY_TEST_API_URL).
6363
- name: Pytest
6464
run: uv run pytest -m 'not integration'
65+
66+
- name: Verify tools from installed wheel
67+
run: bash scripts/verify_tools_wheel.sh

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ All notable changes to `atomicmemory` will be documented in this file.
44

55
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).
66

7+
## 1.1.4 (unreleased)
8+
9+
- Add framework-neutral `memory_tools` and `async_memory_tools` factories with
10+
fixed application user scope, strict argument schemas, bounded retrieval
11+
output, and safe model-facing errors.
12+
- Reject HTTP 202 ingest responses with `PendingIngestError` instead of returning
13+
an empty terminal result. Pending or failed writes must not be retried blindly.
14+
- Add a sync/async tools example and real-HTTP contract verification.
15+
716
## [1.1.3] - 2026-09-22
817

918
### Fixed

‎README.md‎

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ This is a Python port of the TypeScript [`atomicmemory-sdk`](https://github.com/
2222
## Status
2323

2424
Stable releases are available on [PyPI](https://pypi.org/project/atomicmemory/).
25-
This source tree prepares version `1.1.3`; consult PyPI for publication status.
25+
This source tree prepares version `1.1.4`; consult PyPI for publication status.
2626

2727
## Installation
2828

@@ -85,6 +85,68 @@ async def main() -> None:
8585
asyncio.run(main())
8686
```
8787

88+
## Agent-selected memory tools
89+
90+
The following API is new in source version **1.1.4**, which is not yet published.
91+
The published 1.1.3 package does not export these factories. For contributor
92+
verification, use `uv sync --all-extras` in this checkout.
93+
94+
```python
95+
import os
96+
from atomicmemory import MemoryClient, memory_tools
97+
98+
with MemoryClient(providers={"atomicmemory": {
99+
"api_url": "https://api.atomicstrata.ai",
100+
"api_key": os.environ["ATOMICMEMORY_API_KEY"],
101+
}}) as client:
102+
client.initialize()
103+
tools = memory_tools(client=client, user="authenticated-app-user")
104+
result = tools["memory_ingest"].execute({"content": "I prefer tea."})
105+
page = tools["memory_search"].execute({"query": "drink preference"})
106+
```
107+
108+
Each descriptor has `name`, `description`, `parameters` as JSON Schema, and
109+
`execute(arguments)`. Register those with your agent framework and serialize
110+
results with `model_dump(mode="json", exclude_none=True)`. For async code, use
111+
`AsyncMemoryClient`, `await client.initialize()`, `async_memory_tools`, and
112+
`await tools[name].execute(arguments)`.
113+
114+
User identity, endpoint and credentials stay in application configuration.
115+
Arguments accept only `content` for ingest or `query` and an optional `limit`
116+
for search. Both operations keep the configured user, and reject model-supplied
117+
identity or transport fields. Search defaults to five hits, accepts limits up
118+
to 20, and bounds displayed text while retaining score, version and retrieval
119+
evidence. Backend metadata is excluded from model-facing hits.
120+
121+
The tools propagate a safe `MemoryToolError` rather than raw backend errors.
122+
Async cancellation propagates. No write is automatically retried. HTTP 202
123+
is pending, and the direct SDK raises `PendingIngestError`; tools fail safely.
124+
Empty ingest arrays do not confirm a save. IDs are backend reports, not terminal
125+
correction receipts. See [ATO-2333](https://linear.app/atomic-strata/issue/ATO-2333)
126+
and [ATO-2334](https://linear.app/atomic-strata/issue/ATO-2334) for those contracts.
127+
128+
The [single-operation example](examples/memory_tools.py) exercises separate
129+
processes against your explicitly configured backend:
130+
131+
```bash
132+
export ATOMICMEMORY_API_KEY=your-server-key
133+
export ATOMICMEMORY_USER=synthetic-demo-user
134+
uv run python examples/memory_tools.py ingest 'I prefer tea.'
135+
uv run python examples/memory_tools.py search 'drink preference' --async
136+
uv run python examples/memory_tools.py ingest 'I now prefer coffee.' --async
137+
uv run python examples/memory_tools.py search 'drink preference'
138+
```
139+
140+
Use `ATOMICMEMORY_API_URL=http://localhost:17350` for a local Core with its
141+
appropriate explicit key. A local transport fixture verifies sync/async wire
142+
parity, process restart and synthetic correction; it does not verify hosted
143+
persistence or actual engine correction. Hosted completion still depends on
144+
[ATO-2425](https://linear.app/atomic-strata/issue/ATO-2425). Python requires
145+
explicit provider configuration; it does not inherit TypeScript's zero-argument
146+
constructor behavior. Agent-selected tools let the agent choose when to read
147+
and write. Automatic capture/retrieval remains a separate deferred investigation
148+
in [ATO-2420](https://linear.app/atomic-strata/issue/ATO-2420).
149+
88150
## AtomicMemory-specific features
89151

90152
When configured with the `atomicmemory` provider, the client exposes a typed handle for backend-specific routes:

‎atomicmemory/__init__.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
InvalidScopeError,
2121
NetworkError,
2222
NotInitializedError,
23+
PendingIngestError,
2324
ProviderError,
2425
RateLimitError,
2526
UnsupportedOperationError,
@@ -124,6 +125,16 @@
124125
VerificationResult,
125126
VerifyArtifactOptions,
126127
)
128+
from atomicmemory.tools import (
129+
AsyncMemoryTool,
130+
AsyncMemoryTools,
131+
MemoryTool,
132+
MemoryToolError,
133+
MemoryTools,
134+
async_memory_tools,
135+
memory_tools,
136+
)
137+
from atomicmemory.tools_models import MemorySearchHit, MemorySearchOutput
127138

128139
__all__ = [
129140
"DEFAULT_META_FACT_PATTERNS",
@@ -136,6 +147,8 @@
136147
"AsyncAtomicMemoryClient",
137148
"AsyncEntitiesClient",
138149
"AsyncMemoryClient",
150+
"AsyncMemoryTool",
151+
"AsyncMemoryTools",
139152
"AsyncProviderStatus",
140153
"AsyncStorageClient",
141154
"AtomicMemoryClient",
@@ -190,6 +203,11 @@
190203
"MemoryKind",
191204
"MemoryNamespaceConfig",
192205
"MemoryRef",
206+
"MemorySearchHit",
207+
"MemorySearchOutput",
208+
"MemoryTool",
209+
"MemoryToolError",
210+
"MemoryTools",
193211
"MemoryVersion",
194212
"MemoryVersionEvent",
195213
"MergeEntitiesResult",
@@ -202,6 +220,7 @@
202220
"NotInitializedError",
203221
"PackageFormat",
204222
"PackageRequest",
223+
"PendingIngestError",
205224
"PointerContentNotManagedError",
206225
"Profile",
207226
"Provenance",
@@ -230,9 +249,11 @@
230249
"VerificationResult",
231250
"VerifyArtifactOptions",
232251
"__version__",
252+
"async_memory_tools",
233253
"capability_gaps",
234254
"filter_meta_facts",
235255
"is_meta_fact",
256+
"memory_tools",
236257
"resolve_meta_fact_patterns",
237258
"satisfies_profile",
238259
]

‎atomicmemory/_version.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
__version__: The current package version string (PEP 440).
55
"""
66

7-
__version__ = "1.1.3"
7+
__version__ = "1.1.4"

‎atomicmemory/core/errors.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,17 @@ def __init__(
7575
self.response_body = response_body
7676

7777

78+
class PendingIngestError(ProviderError):
79+
"""HTTP 202 accepted a write; no terminal ingest result is available."""
80+
81+
def __init__(self) -> None:
82+
super().__init__(
83+
"Ingest is pending; saving is not confirmed. Do not retry automatically.",
84+
provider="atomicmemory",
85+
status_code=202,
86+
)
87+
88+
7889
class NetworkError(AtomicMemoryError):
7990
"""A transport-level failure (timeout, connection refused, DNS, etc.)."""
8091

‎atomicmemory/providers/atomicmemory/async_provider.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,9 @@ async def close(self) -> None:
110110
async def do_ingest(self, input: IngestInput) -> IngestResult:
111111
body = _build_ingest_body(input)
112112
path = self._route("/memories/ingest/quick" if input.mode == "verbatim" else "/memories/ingest")
113-
raw = await afetch_json(self._require_client(), self._http_options, path, method="POST", json=body)
113+
raw = await afetch_json(
114+
self._require_client(), self._http_options, path, method="POST", json=body, require_completed=True
115+
)
114116
return to_ingest_result(raw)
115117

116118
def _apply_meta_fact_filter(self, results: list[SearchResult]) -> list[SearchResult]:

‎atomicmemory/providers/atomicmemory/http.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
import httpx
2020

21-
from atomicmemory.core.errors import NetworkError, ProviderError, RateLimitError
21+
from atomicmemory.core.errors import NetworkError, PendingIngestError, ProviderError, RateLimitError
2222

2323
_PROVIDER_NAME = "atomicmemory"
2424

@@ -117,10 +117,13 @@ def fetch_json(
117117
*,
118118
method: str = "GET",
119119
json: Any | None = None,
120+
require_completed: bool = False,
120121
) -> Any:
121122
"""Send a request and return the decoded JSON response body."""
122123
response = _request(client, options, method, path, json=json)
123124
_raise_for_status(response, path)
125+
if require_completed and response.status_code == 202:
126+
raise PendingIngestError()
124127
return response.json()
125128

126129

@@ -211,9 +214,12 @@ async def afetch_json(
211214
*,
212215
method: str = "GET",
213216
json: Any | None = None,
217+
require_completed: bool = False,
214218
) -> Any:
215219
response = await _arequest(client, options, method, path, json=json)
216220
_raise_for_status(response, path)
221+
if require_completed and response.status_code == 202:
222+
raise PendingIngestError()
217223
return response.json()
218224

219225

‎atomicmemory/providers/atomicmemory/mappers.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
from __future__ import annotations
1010

1111
from datetime import datetime, timezone
12-
from typing import Any
12+
from typing import Annotated, Any
13+
14+
from pydantic import Field, TypeAdapter
1315

1416
from atomicmemory.memory.types import (
1517
IngestResult,
@@ -22,6 +24,8 @@
2224
SearchResult,
2325
)
2426

27+
_INGEST_IDS = TypeAdapter(list[Annotated[str, Field(min_length=1)]])
28+
2529
_AUDIT_EVENTS: set[MemoryVersionEvent] = {"created", "updated", "superseded", "invalidated"}
2630

2731

@@ -132,8 +136,8 @@ def to_retrieval_receipt(raw: dict[str, Any]) -> RetrievalReceipt:
132136
def to_ingest_result(raw: dict[str, Any]) -> IngestResult:
133137
"""Map ``POST /memories/ingest[/quick]`` response to V3 IngestResult."""
134138
return IngestResult(
135-
created=list(raw.get("stored_memory_ids") or []),
136-
updated=list(raw.get("updated_memory_ids") or []),
139+
created=_INGEST_IDS.validate_python(raw.get("stored_memory_ids", []), strict=True),
140+
updated=_INGEST_IDS.validate_python(raw.get("updated_memory_ids", []), strict=True),
137141
unchanged=[],
138142
)
139143

‎atomicmemory/providers/atomicmemory/provider.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,9 @@ def close(self) -> None:
102102
def do_ingest(self, input: IngestInput) -> IngestResult:
103103
body = _build_ingest_body(input)
104104
path = self._route("/memories/ingest/quick" if input.mode == "verbatim" else "/memories/ingest")
105-
raw = fetch_json(self._require_client(), self._http_options, path, method="POST", json=body)
105+
raw = fetch_json(
106+
self._require_client(), self._http_options, path, method="POST", json=body, require_completed=True
107+
)
106108
return to_ingest_result(raw)
107109

108110
def _apply_meta_fact_filter(self, results: list[SearchResult]) -> list[SearchResult]:

0 commit comments

Comments
 (0)