mrmemory 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mrmemory-0.1.0/.gitignore +34 -0
- mrmemory-0.1.0/PKG-INFO +113 -0
- mrmemory-0.1.0/README.md +83 -0
- mrmemory-0.1.0/pyproject.toml +49 -0
- mrmemory-0.1.0/src/amr/__init__.py +28 -0
- mrmemory-0.1.0/src/amr/_config.py +47 -0
- mrmemory-0.1.0/src/amr/_http.py +137 -0
- mrmemory-0.1.0/src/amr/async_client.py +154 -0
- mrmemory-0.1.0/src/amr/client.py +202 -0
- mrmemory-0.1.0/src/amr/errors.py +44 -0
- mrmemory-0.1.0/src/amr/py.typed +0 -0
- mrmemory-0.1.0/src/amr/types.py +69 -0
- mrmemory-0.1.0/tests/__init__.py +0 -0
- mrmemory-0.1.0/tests/test_async_client.py +74 -0
- mrmemory-0.1.0/tests/test_client.py +151 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Environment
|
|
2
|
+
.env
|
|
3
|
+
.env.local
|
|
4
|
+
.env.*.local
|
|
5
|
+
|
|
6
|
+
# Rust
|
|
7
|
+
target/
|
|
8
|
+
**/*.rs.bk
|
|
9
|
+
|
|
10
|
+
# Python
|
|
11
|
+
__pycache__/
|
|
12
|
+
*.pyc
|
|
13
|
+
*.egg-info/
|
|
14
|
+
dist/
|
|
15
|
+
.venv/
|
|
16
|
+
venv/
|
|
17
|
+
|
|
18
|
+
# Node/TypeScript
|
|
19
|
+
node_modules/
|
|
20
|
+
dist/
|
|
21
|
+
*.tsbuildinfo
|
|
22
|
+
|
|
23
|
+
# IDE
|
|
24
|
+
.vscode/
|
|
25
|
+
.idea/
|
|
26
|
+
*.swp
|
|
27
|
+
*.swo
|
|
28
|
+
|
|
29
|
+
# OS
|
|
30
|
+
.DS_Store
|
|
31
|
+
Thumbs.db
|
|
32
|
+
|
|
33
|
+
# Docker
|
|
34
|
+
docker-compose.override.yml
|
mrmemory-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mrmemory
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Agent Memory Relay — persistent long-term memory for AI agents
|
|
5
|
+
Project-URL: Homepage, https://mrmemory.dev
|
|
6
|
+
Project-URL: Documentation, https://docs.amr.dev
|
|
7
|
+
Project-URL: Repository, https://github.com/amr-dev/amr-python
|
|
8
|
+
Author-email: AMR <sdk@amr.dev>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: agents,ai,llm,memory,semantic-search
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: httpx<1,>=0.27
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: mypy>=1.13; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
27
|
+
Requires-Dist: respx>=0.22; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# amr — Agent Memory Relay
|
|
32
|
+
|
|
33
|
+
[](https://pypi.org/project/amr/)
|
|
34
|
+
[](LICENSE)
|
|
35
|
+
[](https://mrmemory.dev/docs.html)
|
|
36
|
+
|
|
37
|
+
Persistent long-term memory for AI agents. One line to install, three lines to integrate.
|
|
38
|
+
|
|
39
|
+
**[Docs](https://mrmemory.dev/docs.html)** · **[API Reference](https://mrmemory.dev/docs.html#endpoints)** · **[Website](https://mrmemory.dev)**
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install amr
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quickstart
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from amr import AMR
|
|
51
|
+
|
|
52
|
+
amr = AMR("amr_sk_...") # or set AMR_API_KEY env var
|
|
53
|
+
|
|
54
|
+
# Store a memory
|
|
55
|
+
amr.remember("User prefers dark mode and vim keybindings")
|
|
56
|
+
|
|
57
|
+
# Semantic recall
|
|
58
|
+
memories = amr.recall("What are the user's preferences?")
|
|
59
|
+
for m in memories:
|
|
60
|
+
print(m.content, m.score)
|
|
61
|
+
|
|
62
|
+
# Forget a memory
|
|
63
|
+
amr.forget(memories[0].id)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Async Support
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from amr import AsyncAMR
|
|
70
|
+
|
|
71
|
+
async with AsyncAMR("amr_sk_...") as amr:
|
|
72
|
+
await amr.remember("User prefers dark mode")
|
|
73
|
+
memories = await amr.recall("What does the user prefer?")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Configuration
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
amr = AMR(
|
|
80
|
+
api_key="amr_sk_...", # or set AMR_API_KEY env var
|
|
81
|
+
agent_id="my-assistant", # default agent ID
|
|
82
|
+
namespace="default", # default namespace
|
|
83
|
+
timeout=10.0, # seconds
|
|
84
|
+
max_retries=3, # retry on transient failures
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## API Endpoints
|
|
89
|
+
|
|
90
|
+
All requests go to `https://amr-memory-api.fly.dev`.
|
|
91
|
+
|
|
92
|
+
| Method | Endpoint | Description |
|
|
93
|
+
|--------|----------|-------------|
|
|
94
|
+
| POST | `/v1/remember` | Store a memory |
|
|
95
|
+
| GET | `/v1/recall?q=...` | Semantic search |
|
|
96
|
+
| DELETE | `/v1/forget/:id` | Delete a memory |
|
|
97
|
+
| GET | `/v1/memories` | List all memories |
|
|
98
|
+
|
|
99
|
+
Auth: `Authorization: Bearer amr_sk_...`
|
|
100
|
+
|
|
101
|
+
## Pricing
|
|
102
|
+
|
|
103
|
+
Starts at **$5/mo** — 10K memories, 50K API calls. [Sign up →](https://amr-memory-api.fly.dev/v1/billing/checkout)
|
|
104
|
+
|
|
105
|
+
## Links
|
|
106
|
+
|
|
107
|
+
- **Docs:** https://mrmemory.dev/docs.html
|
|
108
|
+
- **Dashboard:** https://mrmemory.dev
|
|
109
|
+
- **GitHub:** https://github.com/amr-dev/amr-python
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT
|
mrmemory-0.1.0/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# amr — Agent Memory Relay
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/amr/)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](https://mrmemory.dev/docs.html)
|
|
6
|
+
|
|
7
|
+
Persistent long-term memory for AI agents. One line to install, three lines to integrate.
|
|
8
|
+
|
|
9
|
+
**[Docs](https://mrmemory.dev/docs.html)** · **[API Reference](https://mrmemory.dev/docs.html#endpoints)** · **[Website](https://mrmemory.dev)**
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install amr
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from amr import AMR
|
|
21
|
+
|
|
22
|
+
amr = AMR("amr_sk_...") # or set AMR_API_KEY env var
|
|
23
|
+
|
|
24
|
+
# Store a memory
|
|
25
|
+
amr.remember("User prefers dark mode and vim keybindings")
|
|
26
|
+
|
|
27
|
+
# Semantic recall
|
|
28
|
+
memories = amr.recall("What are the user's preferences?")
|
|
29
|
+
for m in memories:
|
|
30
|
+
print(m.content, m.score)
|
|
31
|
+
|
|
32
|
+
# Forget a memory
|
|
33
|
+
amr.forget(memories[0].id)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Async Support
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from amr import AsyncAMR
|
|
40
|
+
|
|
41
|
+
async with AsyncAMR("amr_sk_...") as amr:
|
|
42
|
+
await amr.remember("User prefers dark mode")
|
|
43
|
+
memories = await amr.recall("What does the user prefer?")
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Configuration
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
amr = AMR(
|
|
50
|
+
api_key="amr_sk_...", # or set AMR_API_KEY env var
|
|
51
|
+
agent_id="my-assistant", # default agent ID
|
|
52
|
+
namespace="default", # default namespace
|
|
53
|
+
timeout=10.0, # seconds
|
|
54
|
+
max_retries=3, # retry on transient failures
|
|
55
|
+
)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## API Endpoints
|
|
59
|
+
|
|
60
|
+
All requests go to `https://amr-memory-api.fly.dev`.
|
|
61
|
+
|
|
62
|
+
| Method | Endpoint | Description |
|
|
63
|
+
|--------|----------|-------------|
|
|
64
|
+
| POST | `/v1/remember` | Store a memory |
|
|
65
|
+
| GET | `/v1/recall?q=...` | Semantic search |
|
|
66
|
+
| DELETE | `/v1/forget/:id` | Delete a memory |
|
|
67
|
+
| GET | `/v1/memories` | List all memories |
|
|
68
|
+
|
|
69
|
+
Auth: `Authorization: Bearer amr_sk_...`
|
|
70
|
+
|
|
71
|
+
## Pricing
|
|
72
|
+
|
|
73
|
+
Starts at **$5/mo** — 10K memories, 50K API calls. [Sign up →](https://amr-memory-api.fly.dev/v1/billing/checkout)
|
|
74
|
+
|
|
75
|
+
## Links
|
|
76
|
+
|
|
77
|
+
- **Docs:** https://mrmemory.dev/docs.html
|
|
78
|
+
- **Dashboard:** https://mrmemory.dev
|
|
79
|
+
- **GitHub:** https://github.com/amr-dev/amr-python
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mrmemory"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Agent Memory Relay — persistent long-term memory for AI agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "AMR", email = "sdk@amr.dev" }]
|
|
13
|
+
keywords = ["ai", "agents", "memory", "llm", "semantic-search"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = ["httpx>=0.27,<1"]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.24", "respx>=0.22", "ruff>=0.8", "mypy>=1.13"]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://mrmemory.dev"
|
|
33
|
+
Documentation = "https://docs.amr.dev"
|
|
34
|
+
Repository = "https://github.com/amr-dev/amr-python"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/amr"]
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
target-version = "py310"
|
|
41
|
+
line-length = 100
|
|
42
|
+
|
|
43
|
+
[tool.mypy]
|
|
44
|
+
strict = true
|
|
45
|
+
python_version = "3.10"
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
asyncio_mode = "auto"
|
|
49
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""AMR — Agent Memory Relay. Persistent long-term memory for AI agents."""
|
|
2
|
+
|
|
3
|
+
from amr.client import AMR
|
|
4
|
+
from amr.async_client import AsyncAMR
|
|
5
|
+
from amr.types import Memory, MemoryEvent
|
|
6
|
+
from amr.errors import (
|
|
7
|
+
AMRError,
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
RateLimitError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
ValidationError,
|
|
12
|
+
ServerError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AMR",
|
|
17
|
+
"AsyncAMR",
|
|
18
|
+
"Memory",
|
|
19
|
+
"MemoryEvent",
|
|
20
|
+
"AMRError",
|
|
21
|
+
"AuthenticationError",
|
|
22
|
+
"RateLimitError",
|
|
23
|
+
"NotFoundError",
|
|
24
|
+
"ValidationError",
|
|
25
|
+
"ServerError",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Configuration resolution for AMR clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
DEFAULT_BASE_URL = "https://api.amr.dev/v1"
|
|
9
|
+
DEFAULT_TIMEOUT = 10.0
|
|
10
|
+
DEFAULT_MAX_RETRIES = 3
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class Config:
|
|
15
|
+
"""Resolved client configuration."""
|
|
16
|
+
|
|
17
|
+
api_key: str
|
|
18
|
+
base_url: str
|
|
19
|
+
agent_id: str | None
|
|
20
|
+
namespace: str | None
|
|
21
|
+
timeout: float
|
|
22
|
+
max_retries: int
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def resolve(
|
|
26
|
+
cls,
|
|
27
|
+
api_key: str | None = None,
|
|
28
|
+
base_url: str | None = None,
|
|
29
|
+
agent_id: str | None = None,
|
|
30
|
+
namespace: str | None = None,
|
|
31
|
+
timeout: float | None = None,
|
|
32
|
+
max_retries: int | None = None,
|
|
33
|
+
) -> Config:
|
|
34
|
+
"""Build config from explicit args → env vars → defaults."""
|
|
35
|
+
resolved_key = api_key or os.environ.get("AMR_API_KEY", "")
|
|
36
|
+
if not resolved_key:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
"No API key provided. Pass api_key= or set AMR_API_KEY environment variable."
|
|
39
|
+
)
|
|
40
|
+
return cls(
|
|
41
|
+
api_key=resolved_key,
|
|
42
|
+
base_url=(base_url or os.environ.get("AMR_BASE_URL") or DEFAULT_BASE_URL).rstrip("/"),
|
|
43
|
+
agent_id=agent_id or os.environ.get("AMR_AGENT_ID"),
|
|
44
|
+
namespace=namespace or os.environ.get("AMR_NAMESPACE"),
|
|
45
|
+
timeout=timeout if timeout is not None else DEFAULT_TIMEOUT,
|
|
46
|
+
max_retries=max_retries if max_retries is not None else DEFAULT_MAX_RETRIES,
|
|
47
|
+
)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""HTTP transport with retry logic for AMR."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from amr._config import Config
|
|
12
|
+
from amr.errors import (
|
|
13
|
+
AMRError,
|
|
14
|
+
AuthenticationError,
|
|
15
|
+
NotFoundError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
ServerError,
|
|
18
|
+
ValidationError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_RETRYABLE_STATUS = {429, 500, 502, 503, 504}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
25
|
+
"""Raise an appropriate AMRError for non-2xx responses."""
|
|
26
|
+
code = response.status_code
|
|
27
|
+
if 200 <= code < 300:
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
body = response.json()
|
|
32
|
+
message = body.get("error", body.get("message", response.text))
|
|
33
|
+
except Exception:
|
|
34
|
+
message = response.text or f"HTTP {code}"
|
|
35
|
+
|
|
36
|
+
if code == 401:
|
|
37
|
+
raise AuthenticationError(message, status_code=401)
|
|
38
|
+
if code == 404:
|
|
39
|
+
raise NotFoundError(message)
|
|
40
|
+
if code == 422:
|
|
41
|
+
raise ValidationError(message)
|
|
42
|
+
if code == 429:
|
|
43
|
+
retry_after = float(response.headers.get("retry-after", "1"))
|
|
44
|
+
raise RateLimitError(message, retry_after=retry_after)
|
|
45
|
+
if code >= 500:
|
|
46
|
+
raise ServerError(message, status_code=code)
|
|
47
|
+
raise AMRError(message, status_code=code)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _backoff(attempt: int) -> float:
|
|
51
|
+
"""Exponential backoff with jitter."""
|
|
52
|
+
base = min(2**attempt, 30)
|
|
53
|
+
return base * (0.5 + random.random() * 0.5)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SyncTransport:
|
|
57
|
+
"""Synchronous HTTP transport."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, config: Config) -> None:
|
|
60
|
+
self._config = config
|
|
61
|
+
self._client = httpx.Client(
|
|
62
|
+
base_url=config.base_url,
|
|
63
|
+
headers={
|
|
64
|
+
"Authorization": f"Bearer {config.api_key}",
|
|
65
|
+
"Content-Type": "application/json",
|
|
66
|
+
"User-Agent": "amr-python/0.1.0",
|
|
67
|
+
},
|
|
68
|
+
timeout=config.timeout,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
72
|
+
"""Make an HTTP request with automatic retries."""
|
|
73
|
+
last_exc: Exception | None = None
|
|
74
|
+
for attempt in range(self._config.max_retries + 1):
|
|
75
|
+
try:
|
|
76
|
+
response = self._client.request(method, path, **kwargs)
|
|
77
|
+
if response.status_code in _RETRYABLE_STATUS and attempt < self._config.max_retries:
|
|
78
|
+
wait = float(response.headers.get("retry-after", "0")) or _backoff(attempt)
|
|
79
|
+
time.sleep(wait)
|
|
80
|
+
continue
|
|
81
|
+
_raise_for_status(response)
|
|
82
|
+
if response.status_code == 204:
|
|
83
|
+
return None
|
|
84
|
+
return response.json()
|
|
85
|
+
except (httpx.ConnectError, httpx.ReadTimeout) as exc:
|
|
86
|
+
last_exc = exc
|
|
87
|
+
if attempt < self._config.max_retries:
|
|
88
|
+
time.sleep(_backoff(attempt))
|
|
89
|
+
continue
|
|
90
|
+
raise AMRError(f"Connection failed: {exc}") from exc
|
|
91
|
+
raise last_exc or AMRError("Request failed after retries") # type: ignore[misc]
|
|
92
|
+
|
|
93
|
+
def close(self) -> None:
|
|
94
|
+
self._client.close()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class AsyncTransport:
|
|
98
|
+
"""Asynchronous HTTP transport."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, config: Config) -> None:
|
|
101
|
+
self._config = config
|
|
102
|
+
self._client = httpx.AsyncClient(
|
|
103
|
+
base_url=config.base_url,
|
|
104
|
+
headers={
|
|
105
|
+
"Authorization": f"Bearer {config.api_key}",
|
|
106
|
+
"Content-Type": "application/json",
|
|
107
|
+
"User-Agent": "amr-python/0.1.0",
|
|
108
|
+
},
|
|
109
|
+
timeout=config.timeout,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
async def request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
113
|
+
"""Make an async HTTP request with automatic retries."""
|
|
114
|
+
import asyncio
|
|
115
|
+
|
|
116
|
+
last_exc: Exception | None = None
|
|
117
|
+
for attempt in range(self._config.max_retries + 1):
|
|
118
|
+
try:
|
|
119
|
+
response = await self._client.request(method, path, **kwargs)
|
|
120
|
+
if response.status_code in _RETRYABLE_STATUS and attempt < self._config.max_retries:
|
|
121
|
+
wait = float(response.headers.get("retry-after", "0")) or _backoff(attempt)
|
|
122
|
+
await asyncio.sleep(wait)
|
|
123
|
+
continue
|
|
124
|
+
_raise_for_status(response)
|
|
125
|
+
if response.status_code == 204:
|
|
126
|
+
return None
|
|
127
|
+
return response.json()
|
|
128
|
+
except (httpx.ConnectError, httpx.ReadTimeout) as exc:
|
|
129
|
+
last_exc = exc
|
|
130
|
+
if attempt < self._config.max_retries:
|
|
131
|
+
await asyncio.sleep(_backoff(attempt))
|
|
132
|
+
continue
|
|
133
|
+
raise AMRError(f"Connection failed: {exc}") from exc
|
|
134
|
+
raise last_exc or AMRError("Request failed after retries") # type: ignore[misc]
|
|
135
|
+
|
|
136
|
+
async def close(self) -> None:
|
|
137
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Asynchronous AMR client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from amr._config import Config
|
|
9
|
+
from amr._http import AsyncTransport
|
|
10
|
+
from amr.types import Memory
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AsyncAMR:
|
|
14
|
+
"""Asynchronous client for Agent Memory Relay.
|
|
15
|
+
|
|
16
|
+
Usage::
|
|
17
|
+
|
|
18
|
+
from amr import AsyncAMR
|
|
19
|
+
|
|
20
|
+
async with AsyncAMR("amr_sk_...") as amr:
|
|
21
|
+
await amr.remember("User prefers dark mode", tags=["preferences"])
|
|
22
|
+
memories = await amr.recall("What does the user prefer?")
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
api_key: str | None = None,
|
|
28
|
+
*,
|
|
29
|
+
base_url: str | None = None,
|
|
30
|
+
agent_id: str | None = None,
|
|
31
|
+
namespace: str | None = None,
|
|
32
|
+
timeout: float | None = None,
|
|
33
|
+
max_retries: int | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._config = Config.resolve(
|
|
36
|
+
api_key=api_key,
|
|
37
|
+
base_url=base_url,
|
|
38
|
+
agent_id=agent_id,
|
|
39
|
+
namespace=namespace,
|
|
40
|
+
timeout=timeout,
|
|
41
|
+
max_retries=max_retries,
|
|
42
|
+
)
|
|
43
|
+
self._transport = AsyncTransport(self._config)
|
|
44
|
+
|
|
45
|
+
# -- Context manager --
|
|
46
|
+
|
|
47
|
+
async def __aenter__(self) -> AsyncAMR:
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
51
|
+
await self.close()
|
|
52
|
+
|
|
53
|
+
async def close(self) -> None:
|
|
54
|
+
"""Close the underlying HTTP connection pool."""
|
|
55
|
+
await self._transport.close()
|
|
56
|
+
|
|
57
|
+
# -- Core API --
|
|
58
|
+
|
|
59
|
+
async def remember(
|
|
60
|
+
self,
|
|
61
|
+
content: str,
|
|
62
|
+
*,
|
|
63
|
+
tags: list[str] | None = None,
|
|
64
|
+
namespace: str | None = None,
|
|
65
|
+
agent_id: str | None = None,
|
|
66
|
+
ttl: timedelta | None = None,
|
|
67
|
+
) -> Memory:
|
|
68
|
+
"""Store a memory."""
|
|
69
|
+
body: dict[str, Any] = {"content": content}
|
|
70
|
+
if tags:
|
|
71
|
+
body["tags"] = tags
|
|
72
|
+
if namespace or self._config.namespace:
|
|
73
|
+
body["namespace"] = namespace or self._config.namespace
|
|
74
|
+
if agent_id or self._config.agent_id:
|
|
75
|
+
body["agent_id"] = agent_id or self._config.agent_id
|
|
76
|
+
if ttl is not None:
|
|
77
|
+
body["ttl"] = int(ttl.total_seconds())
|
|
78
|
+
|
|
79
|
+
data = await self._transport.request("POST", "/remember", json=body)
|
|
80
|
+
return Memory.from_dict(data)
|
|
81
|
+
|
|
82
|
+
async def recall(
|
|
83
|
+
self,
|
|
84
|
+
query: str,
|
|
85
|
+
*,
|
|
86
|
+
namespace: str | None = None,
|
|
87
|
+
agent_id: str | None = None,
|
|
88
|
+
tags: list[str] | None = None,
|
|
89
|
+
limit: int = 5,
|
|
90
|
+
threshold: float = 0.7,
|
|
91
|
+
) -> list[Memory]:
|
|
92
|
+
"""Retrieve relevant memories via semantic search."""
|
|
93
|
+
body: dict[str, Any] = {"query": query, "limit": limit, "threshold": threshold}
|
|
94
|
+
if tags:
|
|
95
|
+
body["tags"] = tags
|
|
96
|
+
if namespace or self._config.namespace:
|
|
97
|
+
body["namespace"] = namespace or self._config.namespace
|
|
98
|
+
if agent_id or self._config.agent_id:
|
|
99
|
+
body["agent_id"] = agent_id or self._config.agent_id
|
|
100
|
+
|
|
101
|
+
data = await self._transport.request("POST", "/recall", json=body)
|
|
102
|
+
return [Memory.from_dict(m) for m in data.get("memories", data if isinstance(data, list) else [])]
|
|
103
|
+
|
|
104
|
+
async def share(
|
|
105
|
+
self,
|
|
106
|
+
memory_ids: str | list[str],
|
|
107
|
+
*,
|
|
108
|
+
target_agent: str,
|
|
109
|
+
permissions: str = "read",
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Share memories with another agent."""
|
|
112
|
+
ids = [memory_ids] if isinstance(memory_ids, str) else memory_ids
|
|
113
|
+
await self._transport.request(
|
|
114
|
+
"POST",
|
|
115
|
+
"/share",
|
|
116
|
+
json={
|
|
117
|
+
"memory_ids": ids,
|
|
118
|
+
"target_agent_id": target_agent,
|
|
119
|
+
"permissions": permissions,
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
async def forget(self, memory_ids: str | list[str]) -> None:
|
|
124
|
+
"""Delete one or more memories."""
|
|
125
|
+
ids = [memory_ids] if isinstance(memory_ids, str) else memory_ids
|
|
126
|
+
await self._transport.request("DELETE", "/forget", json={"memory_ids": ids})
|
|
127
|
+
|
|
128
|
+
async def forget_all(self, *, namespace: str | None = None) -> None:
|
|
129
|
+
"""Delete all memories, optionally scoped to a namespace."""
|
|
130
|
+
body: dict[str, Any] = {"forget_all": True}
|
|
131
|
+
if namespace:
|
|
132
|
+
body["namespace"] = namespace
|
|
133
|
+
await self._transport.request("DELETE", "/forget", json=body)
|
|
134
|
+
|
|
135
|
+
async def memories(
|
|
136
|
+
self,
|
|
137
|
+
*,
|
|
138
|
+
namespace: str | None = None,
|
|
139
|
+
agent_id: str | None = None,
|
|
140
|
+
tags: list[str] | None = None,
|
|
141
|
+
limit: int = 20,
|
|
142
|
+
offset: int = 0,
|
|
143
|
+
) -> list[Memory]:
|
|
144
|
+
"""List memories with optional filters."""
|
|
145
|
+
params: dict[str, Any] = {"limit": limit, "offset": offset}
|
|
146
|
+
if namespace or self._config.namespace:
|
|
147
|
+
params["namespace"] = namespace or self._config.namespace
|
|
148
|
+
if agent_id or self._config.agent_id:
|
|
149
|
+
params["agent_id"] = agent_id or self._config.agent_id
|
|
150
|
+
if tags:
|
|
151
|
+
params["tags"] = ",".join(tags)
|
|
152
|
+
|
|
153
|
+
data = await self._transport.request("GET", "/memories", params=params)
|
|
154
|
+
return [Memory.from_dict(m) for m in data.get("memories", data if isinstance(data, list) else [])]
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Synchronous AMR client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from amr._config import Config
|
|
9
|
+
from amr._http import SyncTransport
|
|
10
|
+
from amr.types import Memory
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AMR:
|
|
14
|
+
"""Synchronous client for Agent Memory Relay.
|
|
15
|
+
|
|
16
|
+
Usage::
|
|
17
|
+
|
|
18
|
+
from amr import AMR
|
|
19
|
+
|
|
20
|
+
amr = AMR("amr_sk_...")
|
|
21
|
+
amr.remember("User prefers dark mode", tags=["preferences"])
|
|
22
|
+
memories = amr.recall("What does the user prefer?")
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
api_key: str | None = None,
|
|
28
|
+
*,
|
|
29
|
+
base_url: str | None = None,
|
|
30
|
+
agent_id: str | None = None,
|
|
31
|
+
namespace: str | None = None,
|
|
32
|
+
timeout: float | None = None,
|
|
33
|
+
max_retries: int | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._config = Config.resolve(
|
|
36
|
+
api_key=api_key,
|
|
37
|
+
base_url=base_url,
|
|
38
|
+
agent_id=agent_id,
|
|
39
|
+
namespace=namespace,
|
|
40
|
+
timeout=timeout,
|
|
41
|
+
max_retries=max_retries,
|
|
42
|
+
)
|
|
43
|
+
self._transport = SyncTransport(self._config)
|
|
44
|
+
|
|
45
|
+
# -- Context manager --
|
|
46
|
+
|
|
47
|
+
def __enter__(self) -> AMR:
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def __exit__(self, *_: Any) -> None:
|
|
51
|
+
self.close()
|
|
52
|
+
|
|
53
|
+
def close(self) -> None:
|
|
54
|
+
"""Close the underlying HTTP connection pool."""
|
|
55
|
+
self._transport.close()
|
|
56
|
+
|
|
57
|
+
# -- Core API --
|
|
58
|
+
|
|
59
|
+
def remember(
|
|
60
|
+
self,
|
|
61
|
+
content: str,
|
|
62
|
+
*,
|
|
63
|
+
tags: list[str] | None = None,
|
|
64
|
+
namespace: str | None = None,
|
|
65
|
+
agent_id: str | None = None,
|
|
66
|
+
ttl: timedelta | None = None,
|
|
67
|
+
) -> Memory:
|
|
68
|
+
"""Store a memory.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
content: The memory text to store.
|
|
72
|
+
tags: Optional tags for filtering.
|
|
73
|
+
namespace: Override the client default namespace.
|
|
74
|
+
agent_id: Override the client default agent ID.
|
|
75
|
+
ttl: Auto-expire after this duration.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
The created Memory object.
|
|
79
|
+
"""
|
|
80
|
+
body: dict[str, Any] = {"content": content}
|
|
81
|
+
if tags:
|
|
82
|
+
body["tags"] = tags
|
|
83
|
+
if namespace or self._config.namespace:
|
|
84
|
+
body["namespace"] = namespace or self._config.namespace
|
|
85
|
+
if agent_id or self._config.agent_id:
|
|
86
|
+
body["agent_id"] = agent_id or self._config.agent_id
|
|
87
|
+
if ttl is not None:
|
|
88
|
+
body["ttl"] = int(ttl.total_seconds())
|
|
89
|
+
|
|
90
|
+
data = self._transport.request("POST", "/remember", json=body)
|
|
91
|
+
return Memory.from_dict(data)
|
|
92
|
+
|
|
93
|
+
def recall(
|
|
94
|
+
self,
|
|
95
|
+
query: str,
|
|
96
|
+
*,
|
|
97
|
+
namespace: str | None = None,
|
|
98
|
+
agent_id: str | None = None,
|
|
99
|
+
tags: list[str] | None = None,
|
|
100
|
+
limit: int = 5,
|
|
101
|
+
threshold: float = 0.7,
|
|
102
|
+
) -> list[Memory]:
|
|
103
|
+
"""Retrieve relevant memories via semantic search.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
query: Natural language query.
|
|
107
|
+
namespace: Filter by namespace.
|
|
108
|
+
agent_id: Filter by agent ID.
|
|
109
|
+
tags: Filter by tags (AND).
|
|
110
|
+
limit: Maximum results (default 5).
|
|
111
|
+
threshold: Minimum similarity score (default 0.7).
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
List of Memory objects ranked by relevance.
|
|
115
|
+
"""
|
|
116
|
+
body: dict[str, Any] = {"query": query, "limit": limit, "threshold": threshold}
|
|
117
|
+
if tags:
|
|
118
|
+
body["tags"] = tags
|
|
119
|
+
if namespace or self._config.namespace:
|
|
120
|
+
body["namespace"] = namespace or self._config.namespace
|
|
121
|
+
if agent_id or self._config.agent_id:
|
|
122
|
+
body["agent_id"] = agent_id or self._config.agent_id
|
|
123
|
+
|
|
124
|
+
data = self._transport.request("POST", "/recall", json=body)
|
|
125
|
+
return [Memory.from_dict(m) for m in data.get("memories", data if isinstance(data, list) else [])]
|
|
126
|
+
|
|
127
|
+
def share(
|
|
128
|
+
self,
|
|
129
|
+
memory_ids: str | list[str],
|
|
130
|
+
*,
|
|
131
|
+
target_agent: str,
|
|
132
|
+
permissions: str = "read",
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Share memories with another agent.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
memory_ids: Single ID or list of memory IDs to share.
|
|
138
|
+
target_agent: The target agent ID.
|
|
139
|
+
permissions: "read" (default) or "readwrite".
|
|
140
|
+
"""
|
|
141
|
+
ids = [memory_ids] if isinstance(memory_ids, str) else memory_ids
|
|
142
|
+
self._transport.request(
|
|
143
|
+
"POST",
|
|
144
|
+
"/share",
|
|
145
|
+
json={
|
|
146
|
+
"memory_ids": ids,
|
|
147
|
+
"target_agent_id": target_agent,
|
|
148
|
+
"permissions": permissions,
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def forget(self, memory_ids: str | list[str]) -> None:
|
|
153
|
+
"""Delete one or more memories.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
memory_ids: Single ID or list of memory IDs to delete.
|
|
157
|
+
"""
|
|
158
|
+
ids = [memory_ids] if isinstance(memory_ids, str) else memory_ids
|
|
159
|
+
self._transport.request("DELETE", "/forget", json={"memory_ids": ids})
|
|
160
|
+
|
|
161
|
+
def forget_all(self, *, namespace: str | None = None) -> None:
|
|
162
|
+
"""Delete all memories, optionally scoped to a namespace.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
namespace: If provided, only delete memories in this namespace.
|
|
166
|
+
"""
|
|
167
|
+
body: dict[str, Any] = {"forget_all": True}
|
|
168
|
+
if namespace:
|
|
169
|
+
body["namespace"] = namespace
|
|
170
|
+
self._transport.request("DELETE", "/forget", json=body)
|
|
171
|
+
|
|
172
|
+
def memories(
|
|
173
|
+
self,
|
|
174
|
+
*,
|
|
175
|
+
namespace: str | None = None,
|
|
176
|
+
agent_id: str | None = None,
|
|
177
|
+
tags: list[str] | None = None,
|
|
178
|
+
limit: int = 20,
|
|
179
|
+
offset: int = 0,
|
|
180
|
+
) -> list[Memory]:
|
|
181
|
+
"""List memories with optional filters.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
namespace: Filter by namespace.
|
|
185
|
+
agent_id: Filter by agent ID.
|
|
186
|
+
tags: Filter by tags.
|
|
187
|
+
limit: Page size (default 20).
|
|
188
|
+
offset: Pagination offset.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
List of Memory objects.
|
|
192
|
+
"""
|
|
193
|
+
params: dict[str, Any] = {"limit": limit, "offset": offset}
|
|
194
|
+
if namespace or self._config.namespace:
|
|
195
|
+
params["namespace"] = namespace or self._config.namespace
|
|
196
|
+
if agent_id or self._config.agent_id:
|
|
197
|
+
params["agent_id"] = agent_id or self._config.agent_id
|
|
198
|
+
if tags:
|
|
199
|
+
params["tags"] = ",".join(tags)
|
|
200
|
+
|
|
201
|
+
data = self._transport.request("GET", "/memories", params=params)
|
|
202
|
+
return [Memory.from_dict(m) for m in data.get("memories", data if isinstance(data, list) else [])]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""AMR exception hierarchy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AMRError(Exception):
|
|
7
|
+
"""Base exception for all AMR errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, status_code: int | None = None) -> None:
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.status_code = status_code
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AuthenticationError(AMRError):
|
|
15
|
+
"""Invalid or missing API key (401)."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RateLimitError(AMRError):
|
|
19
|
+
"""Too many requests (429)."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, retry_after: float = 1.0) -> None:
|
|
22
|
+
super().__init__(message, status_code=429)
|
|
23
|
+
self.retry_after = retry_after
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NotFoundError(AMRError):
|
|
27
|
+
"""Resource not found (404)."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, message: str = "Not found") -> None:
|
|
30
|
+
super().__init__(message, status_code=404)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ValidationError(AMRError):
|
|
34
|
+
"""Invalid request parameters (422)."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, message: str = "Validation error") -> None:
|
|
37
|
+
super().__init__(message, status_code=422)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ServerError(AMRError):
|
|
41
|
+
"""AMR server error (5xx)."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, message: str = "Server error", status_code: int = 500) -> None:
|
|
44
|
+
super().__init__(message, status_code=status_code)
|
|
File without changes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""AMR data types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class Memory:
|
|
12
|
+
"""A stored memory."""
|
|
13
|
+
|
|
14
|
+
id: str
|
|
15
|
+
content: str
|
|
16
|
+
tags: list[str] = field(default_factory=list)
|
|
17
|
+
namespace: str = "default"
|
|
18
|
+
agent_id: str = ""
|
|
19
|
+
score: float | None = None
|
|
20
|
+
ttl: timedelta | None = None
|
|
21
|
+
expires_at: datetime | None = None
|
|
22
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
23
|
+
updated_at: datetime = field(default_factory=datetime.now)
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_dict(cls, data: dict) -> Memory: # type: ignore[type-arg]
|
|
27
|
+
"""Parse a Memory from an API response dict."""
|
|
28
|
+
return cls(
|
|
29
|
+
id=data["id"],
|
|
30
|
+
content=data["content"],
|
|
31
|
+
tags=data.get("tags", []),
|
|
32
|
+
namespace=data.get("namespace", "default"),
|
|
33
|
+
agent_id=data.get("agent_id", ""),
|
|
34
|
+
score=data.get("score"),
|
|
35
|
+
ttl=timedelta(seconds=data["ttl"]) if data.get("ttl") else None,
|
|
36
|
+
expires_at=_parse_dt(data.get("expires_at")),
|
|
37
|
+
created_at=_parse_dt(data.get("created_at")) or datetime.now(),
|
|
38
|
+
updated_at=_parse_dt(data.get("updated_at")) or datetime.now(),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class MemoryEvent:
|
|
44
|
+
"""A real-time memory event from the WebSocket stream."""
|
|
45
|
+
|
|
46
|
+
type: Literal["memory.created", "memory.shared", "memory.expired"]
|
|
47
|
+
memory_id: str
|
|
48
|
+
memory: Memory | None = None
|
|
49
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, data: dict) -> MemoryEvent: # type: ignore[type-arg]
|
|
53
|
+
"""Parse a MemoryEvent from a WebSocket message."""
|
|
54
|
+
mem = Memory.from_dict(data["memory"]) if data.get("memory") else None
|
|
55
|
+
return cls(
|
|
56
|
+
type=data["type"],
|
|
57
|
+
memory_id=data.get("memory_id", mem.id if mem else ""),
|
|
58
|
+
memory=mem,
|
|
59
|
+
timestamp=_parse_dt(data.get("timestamp")) or datetime.now(),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_dt(value: str | None) -> datetime | None:
|
|
64
|
+
"""Parse an ISO 8601 datetime string."""
|
|
65
|
+
if value is None:
|
|
66
|
+
return None
|
|
67
|
+
# Handle trailing Z
|
|
68
|
+
s = value.replace("Z", "+00:00")
|
|
69
|
+
return datetime.fromisoformat(s)
|
|
File without changes
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Tests for the async AMR client."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
import respx
|
|
6
|
+
|
|
7
|
+
from amr import AsyncAMR, Memory
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
API_KEY = "amr_sk_test_key_1234567890"
|
|
11
|
+
BASE_URL = "https://api.amr.dev/v1"
|
|
12
|
+
|
|
13
|
+
MOCK_MEMORY = {
|
|
14
|
+
"id": "mem_abc123",
|
|
15
|
+
"content": "User prefers dark mode",
|
|
16
|
+
"tags": ["preferences"],
|
|
17
|
+
"namespace": "default",
|
|
18
|
+
"agent_id": "test-agent",
|
|
19
|
+
"created_at": "2026-03-12T10:00:00Z",
|
|
20
|
+
"updated_at": "2026-03-12T10:00:00Z",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@respx.mock
|
|
25
|
+
async def test_async_remember():
|
|
26
|
+
respx.post(f"{BASE_URL}/remember").mock(
|
|
27
|
+
return_value=httpx.Response(201, json=MOCK_MEMORY)
|
|
28
|
+
)
|
|
29
|
+
async with AsyncAMR(API_KEY) as amr:
|
|
30
|
+
memory = await amr.remember("User prefers dark mode", tags=["preferences"])
|
|
31
|
+
|
|
32
|
+
assert isinstance(memory, Memory)
|
|
33
|
+
assert memory.id == "mem_abc123"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@respx.mock
|
|
37
|
+
async def test_async_recall():
|
|
38
|
+
respx.post(f"{BASE_URL}/recall").mock(
|
|
39
|
+
return_value=httpx.Response(200, json={"memories": [{**MOCK_MEMORY, "score": 0.92}]})
|
|
40
|
+
)
|
|
41
|
+
async with AsyncAMR(API_KEY) as amr:
|
|
42
|
+
memories = await amr.recall("dark mode?")
|
|
43
|
+
|
|
44
|
+
assert len(memories) == 1
|
|
45
|
+
assert memories[0].score == 0.92
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@respx.mock
|
|
49
|
+
async def test_async_share():
|
|
50
|
+
respx.post(f"{BASE_URL}/share").mock(
|
|
51
|
+
return_value=httpx.Response(200, json={"ok": True})
|
|
52
|
+
)
|
|
53
|
+
async with AsyncAMR(API_KEY) as amr:
|
|
54
|
+
await amr.share("mem_abc123", target_agent="agent-2")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@respx.mock
|
|
58
|
+
async def test_async_forget():
|
|
59
|
+
respx.delete(f"{BASE_URL}/forget").mock(
|
|
60
|
+
return_value=httpx.Response(204)
|
|
61
|
+
)
|
|
62
|
+
async with AsyncAMR(API_KEY) as amr:
|
|
63
|
+
await amr.forget("mem_abc123")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@respx.mock
|
|
67
|
+
async def test_async_memories():
|
|
68
|
+
respx.get(f"{BASE_URL}/memories").mock(
|
|
69
|
+
return_value=httpx.Response(200, json={"memories": [MOCK_MEMORY]})
|
|
70
|
+
)
|
|
71
|
+
async with AsyncAMR(API_KEY) as amr:
|
|
72
|
+
memories = await amr.memories()
|
|
73
|
+
|
|
74
|
+
assert len(memories) == 1
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Tests for the synchronous AMR client."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
import respx
|
|
6
|
+
|
|
7
|
+
from amr import AMR, Memory
|
|
8
|
+
from amr.errors import AuthenticationError, RateLimitError, NotFoundError, ValidationError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
API_KEY = "amr_sk_test_key_1234567890"
|
|
12
|
+
BASE_URL = "https://api.amr.dev/v1"
|
|
13
|
+
|
|
14
|
+
MOCK_MEMORY = {
|
|
15
|
+
"id": "mem_abc123",
|
|
16
|
+
"content": "User prefers dark mode",
|
|
17
|
+
"tags": ["preferences"],
|
|
18
|
+
"namespace": "default",
|
|
19
|
+
"agent_id": "test-agent",
|
|
20
|
+
"created_at": "2026-03-12T10:00:00Z",
|
|
21
|
+
"updated_at": "2026-03-12T10:00:00Z",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@respx.mock
|
|
26
|
+
def test_remember():
|
|
27
|
+
respx.post(f"{BASE_URL}/remember").mock(
|
|
28
|
+
return_value=httpx.Response(201, json=MOCK_MEMORY)
|
|
29
|
+
)
|
|
30
|
+
with AMR(API_KEY) as amr:
|
|
31
|
+
memory = amr.remember("User prefers dark mode", tags=["preferences"])
|
|
32
|
+
|
|
33
|
+
assert isinstance(memory, Memory)
|
|
34
|
+
assert memory.id == "mem_abc123"
|
|
35
|
+
assert memory.content == "User prefers dark mode"
|
|
36
|
+
assert memory.tags == ["preferences"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@respx.mock
|
|
40
|
+
def test_recall():
|
|
41
|
+
respx.post(f"{BASE_URL}/recall").mock(
|
|
42
|
+
return_value=httpx.Response(200, json={"memories": [{**MOCK_MEMORY, "score": 0.95}]})
|
|
43
|
+
)
|
|
44
|
+
with AMR(API_KEY) as amr:
|
|
45
|
+
memories = amr.recall("What does the user prefer?")
|
|
46
|
+
|
|
47
|
+
assert len(memories) == 1
|
|
48
|
+
assert memories[0].score == 0.95
|
|
49
|
+
assert memories[0].content == "User prefers dark mode"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@respx.mock
|
|
53
|
+
def test_share():
|
|
54
|
+
respx.post(f"{BASE_URL}/share").mock(
|
|
55
|
+
return_value=httpx.Response(200, json={"ok": True})
|
|
56
|
+
)
|
|
57
|
+
with AMR(API_KEY) as amr:
|
|
58
|
+
amr.share("mem_abc123", target_agent="agent-2") # no exception = success
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@respx.mock
|
|
62
|
+
def test_forget():
|
|
63
|
+
respx.delete(f"{BASE_URL}/forget").mock(
|
|
64
|
+
return_value=httpx.Response(204)
|
|
65
|
+
)
|
|
66
|
+
with AMR(API_KEY) as amr:
|
|
67
|
+
amr.forget("mem_abc123") # no exception = success
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@respx.mock
|
|
71
|
+
def test_forget_all():
|
|
72
|
+
respx.delete(f"{BASE_URL}/forget").mock(
|
|
73
|
+
return_value=httpx.Response(204)
|
|
74
|
+
)
|
|
75
|
+
with AMR(API_KEY) as amr:
|
|
76
|
+
amr.forget_all(namespace="test")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@respx.mock
|
|
80
|
+
def test_memories_list():
|
|
81
|
+
respx.get(f"{BASE_URL}/memories").mock(
|
|
82
|
+
return_value=httpx.Response(200, json={"memories": [MOCK_MEMORY]})
|
|
83
|
+
)
|
|
84
|
+
with AMR(API_KEY) as amr:
|
|
85
|
+
memories = amr.memories(limit=10)
|
|
86
|
+
|
|
87
|
+
assert len(memories) == 1
|
|
88
|
+
assert memories[0].id == "mem_abc123"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@respx.mock
|
|
92
|
+
def test_auth_error():
|
|
93
|
+
respx.post(f"{BASE_URL}/remember").mock(
|
|
94
|
+
return_value=httpx.Response(401, json={"error": "Invalid API key"})
|
|
95
|
+
)
|
|
96
|
+
with AMR(API_KEY, max_retries=0) as amr:
|
|
97
|
+
with pytest.raises(AuthenticationError):
|
|
98
|
+
amr.remember("test")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@respx.mock
|
|
102
|
+
def test_rate_limit_error():
|
|
103
|
+
respx.post(f"{BASE_URL}/remember").mock(
|
|
104
|
+
return_value=httpx.Response(429, json={"error": "Too many requests"}, headers={"retry-after": "5"})
|
|
105
|
+
)
|
|
106
|
+
with AMR(API_KEY, max_retries=0) as amr:
|
|
107
|
+
with pytest.raises(RateLimitError) as exc_info:
|
|
108
|
+
amr.remember("test")
|
|
109
|
+
assert exc_info.value.retry_after == 5.0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@respx.mock
|
|
113
|
+
def test_not_found_error():
|
|
114
|
+
respx.delete(f"{BASE_URL}/forget").mock(
|
|
115
|
+
return_value=httpx.Response(404, json={"error": "Memory not found"})
|
|
116
|
+
)
|
|
117
|
+
with AMR(API_KEY, max_retries=0) as amr:
|
|
118
|
+
with pytest.raises(NotFoundError):
|
|
119
|
+
amr.forget("mem_nonexistent")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@respx.mock
|
|
123
|
+
def test_validation_error():
|
|
124
|
+
respx.post(f"{BASE_URL}/remember").mock(
|
|
125
|
+
return_value=httpx.Response(422, json={"error": "content is required"})
|
|
126
|
+
)
|
|
127
|
+
with AMR(API_KEY, max_retries=0) as amr:
|
|
128
|
+
with pytest.raises(ValidationError):
|
|
129
|
+
amr.remember("")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_no_api_key():
|
|
133
|
+
import os
|
|
134
|
+
env_backup = os.environ.pop("AMR_API_KEY", None)
|
|
135
|
+
try:
|
|
136
|
+
with pytest.raises(ValueError, match="No API key"):
|
|
137
|
+
AMR()
|
|
138
|
+
finally:
|
|
139
|
+
if env_backup:
|
|
140
|
+
os.environ["AMR_API_KEY"] = env_backup
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_env_api_key():
|
|
144
|
+
import os
|
|
145
|
+
os.environ["AMR_API_KEY"] = "amr_sk_from_env"
|
|
146
|
+
try:
|
|
147
|
+
amr = AMR()
|
|
148
|
+
assert amr._config.api_key == "amr_sk_from_env"
|
|
149
|
+
amr.close()
|
|
150
|
+
finally:
|
|
151
|
+
del os.environ["AMR_API_KEY"]
|