memtru 0.1.0__py3-none-any.whl

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.
memtru/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Memtru: reproducible reliability and isolation tests for AI agent memory."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ REPORT_SCHEMA_VERSION = "1.0"
6
+
7
+ __all__ = ["REPORT_SCHEMA_VERSION", "__version__"]
@@ -0,0 +1,105 @@
1
+ """Adapter registry: builds the adapter described by the configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import os
7
+ import sys
8
+ from dataclasses import dataclass
9
+
10
+ from memtru.adapters.base import AgentAdapter, MemoryAdapter
11
+ from memtru.adapters.in_memory import InMemoryAdapter
12
+ from memtru.core.config import (
13
+ Config,
14
+ CustomAdapterConfig,
15
+ HttpAdapterConfig,
16
+ InMemoryAdapterConfig,
17
+ Mem0AdapterConfig,
18
+ )
19
+ from memtru.errors import ConfigError
20
+
21
+ __all__ = ["AgentAdapter", "MemoryAdapter", "build_adapter", "describe_builtin_adapters"]
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class AdapterInfo:
26
+ type: str
27
+ summary: str
28
+ verification: str
29
+
30
+
31
+ BUILTIN_ADAPTERS = [
32
+ AdapterInfo(
33
+ "in_memory",
34
+ "Built-in reference store with tenant, user and session scoping. Supports fault injection.",
35
+ "Verified by the Memtru test suite.",
36
+ ),
37
+ AdapterInfo(
38
+ "http",
39
+ "Any service implementing the Memtru REST contract (docs/adapters.md).",
40
+ "Verified against the reference server in examples/provider-integration.",
41
+ ),
42
+ AdapterInfo(
43
+ "mem0",
44
+ "Mem0 OSS 2.2.1; explicit SDK factory, synthetic search-filter testing.",
45
+ "SDK contract tests and local Qdrant integration; hosted verification is separate.",
46
+ ),
47
+ AdapterInfo(
48
+ "custom",
49
+ "Your own Python class implementing memtru.adapters.base.MemoryAdapter.",
50
+ "Verified by you: Memtru only loads it.",
51
+ ),
52
+ ]
53
+
54
+
55
+ def describe_builtin_adapters() -> list[AdapterInfo]:
56
+ return list(BUILTIN_ADAPTERS)
57
+
58
+
59
+ def _load_custom(config: CustomAdapterConfig) -> MemoryAdapter:
60
+ module_name, _, attr = config.entrypoint.partition(":")
61
+ # Like pytest, resolve user modules relative to the directory the CLI runs in.
62
+ if os.getcwd() not in sys.path:
63
+ sys.path.insert(0, os.getcwd())
64
+ try:
65
+ module = importlib.import_module(module_name)
66
+ except ImportError as err:
67
+ raise ConfigError(
68
+ f"Cannot import adapter module {module_name!r}: {err}",
69
+ hint="Make sure the module is importable from the current directory or PYTHONPATH.",
70
+ ) from None
71
+ factory = getattr(module, attr, None)
72
+ if factory is None:
73
+ raise ConfigError(f"{module_name!r} has no attribute {attr!r}.")
74
+ adapter = factory(**config.options)
75
+ if not isinstance(adapter, MemoryAdapter):
76
+ raise ConfigError(f"{config.entrypoint} did not produce a MemoryAdapter instance.")
77
+ return adapter
78
+
79
+
80
+ def build_adapter(config: Config) -> MemoryAdapter:
81
+ adapter_config = config.adapter
82
+ if isinstance(adapter_config, InMemoryAdapterConfig):
83
+ return InMemoryAdapter(adapter_config.faults, adapter_config.index_delay_ms)
84
+ if isinstance(adapter_config, HttpAdapterConfig):
85
+ from memtru.adapters.http import HttpAdapter
86
+
87
+ return HttpAdapter(adapter_config, environment=config.execution.environment)
88
+ if isinstance(adapter_config, CustomAdapterConfig):
89
+ return _load_custom(adapter_config)
90
+ if isinstance(adapter_config, Mem0AdapterConfig):
91
+ from memtru.adapters.mem0 import Mem0Adapter
92
+
93
+ # The optional SDK otherwise enables its own telemetry at import time.
94
+ os.environ["MEM0_TELEMETRY"] = "false"
95
+ module_name, _, attr = adapter_config.factory.partition(":")
96
+ if os.getcwd() not in sys.path:
97
+ sys.path.insert(0, os.getcwd())
98
+ try:
99
+ factory = getattr(importlib.import_module(module_name), attr)
100
+ except (ImportError, AttributeError):
101
+ raise ConfigError(
102
+ "Cannot load Mem0 factory. Check adapter.factory and install memtru[mem0]."
103
+ ) from None
104
+ return Mem0Adapter(factory())
105
+ raise ConfigError(f"Unknown adapter type {adapter_config!r}") # pragma: no cover
@@ -0,0 +1,80 @@
1
+ """The adapter contract every memory provider integration implements.
2
+
3
+ Adapters translate Memtru operations into calls on a real memory system.
4
+ They must scope every operation to the given :class:`MemoryContext`; the
5
+ scenarios then verify whether that scoping actually holds.
6
+
7
+ Optional operations raise :class:`UnsupportedCapabilityError` by default.
8
+ Declare what is supported in :meth:`MemoryAdapter.capabilities` so scenarios
9
+ can be skipped, never silently passed, when a prerequisite is missing.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from abc import ABC, abstractmethod
15
+ from typing import ClassVar
16
+
17
+ from memtru.core.models import Capability, MemoryContext, MemoryRecord, MemoryScope
18
+ from memtru.errors import UnsupportedCapabilityError
19
+
20
+
21
+ class MemoryAdapter(ABC):
22
+ name: ClassVar[str]
23
+ version: ClassVar[str] = "1.0"
24
+
25
+ @abstractmethod
26
+ def capabilities(self) -> frozenset[Capability]:
27
+ """Capabilities this adapter supports *and* boundaries it claims to enforce."""
28
+
29
+ def setup(self, namespace: str) -> None: # noqa: B027 - optional hook
30
+ """Prepare an isolated test namespace. Called once per run."""
31
+
32
+ def teardown(self, namespace: str) -> None: # noqa: B027 - optional hook
33
+ """Release the namespace created by :meth:`setup`. Must not touch other data."""
34
+
35
+ @abstractmethod
36
+ def add(
37
+ self,
38
+ ctx: MemoryContext,
39
+ content: str,
40
+ *,
41
+ metadata: dict[str, str] | None = None,
42
+ scope: MemoryScope = "user",
43
+ ) -> MemoryRecord: ...
44
+
45
+ @abstractmethod
46
+ def search(self, ctx: MemoryContext, query: str, *, limit: int = 10) -> list[MemoryRecord]: ...
47
+
48
+ def get(self, ctx: MemoryContext, memory_id: str) -> MemoryRecord | None:
49
+ raise UnsupportedCapabilityError(f"{self.name} does not support read by id")
50
+
51
+ def update(
52
+ self,
53
+ ctx: MemoryContext,
54
+ memory_id: str,
55
+ content: str,
56
+ *,
57
+ metadata: dict[str, str] | None = None,
58
+ ) -> MemoryRecord:
59
+ raise UnsupportedCapabilityError(f"{self.name} does not support updates")
60
+
61
+ def delete(self, ctx: MemoryContext, memory_id: str) -> None:
62
+ raise UnsupportedCapabilityError(f"{self.name} does not support deletion")
63
+
64
+ def describe(self) -> dict[str, str]:
65
+ """Safe, non-secret metadata included in reports."""
66
+ return {}
67
+
68
+ def close(self) -> None: # noqa: B027 - optional hook
69
+ """Release connections."""
70
+
71
+
72
+ class AgentAdapter(MemoryAdapter):
73
+ """An adapter that can also run the agent on top of the memory store.
74
+
75
+ Required for agent-mode scenarios (MT-POISON-007). ``respond`` must run the
76
+ real agent with the memory visible to ``ctx`` and return its final output.
77
+ """
78
+
79
+ @abstractmethod
80
+ def respond(self, ctx: MemoryContext, prompt: str) -> str: ...
@@ -0,0 +1,239 @@
1
+ """Adapter for any memory service that implements the Memtru REST contract.
2
+
3
+ Contract (see docs/adapters.md for the full description)::
4
+
5
+ GET /capabilities -> {"capabilities": ["MEMORY_WRITE", ...]}
6
+ POST /memories body {content, metadata, scope} -> record
7
+ GET /memories/search?q=..&limit= -> {"results": [record, ...]}
8
+ GET /memories/{id} -> record | 404
9
+ PATCH /memories/{id} body {content, metadata} -> record
10
+ DELETE /memories/{id} -> 204
11
+
12
+ Every request carries the context as headers: X-Memtru-Tenant,
13
+ X-Memtru-User, X-Memtru-Session (optional), X-Memtru-Namespace
14
+ and X-Memtru-Run. A service under test maps those onto its own auth model;
15
+ see docs/adapters.md for why that mapping is itself part of what is tested.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import time
23
+ from typing import Any, ClassVar
24
+ from urllib.parse import quote
25
+
26
+ import httpx
27
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
28
+
29
+ from memtru.adapters.base import MemoryAdapter
30
+ from memtru.core.config import HttpAdapterConfig
31
+ from memtru.core.models import Capability, MemoryContext, MemoryRecord, MemoryScope
32
+ from memtru.errors import (
33
+ AdapterError,
34
+ ConfigError,
35
+ TransientAdapterError,
36
+ UnsafeTargetError,
37
+ )
38
+ from memtru.security.urls import UrlPolicy, check_url
39
+
40
+ MAX_RESPONSE_BYTES = 1_000_000
41
+
42
+
43
+ class _RecordModel(BaseModel):
44
+ model_config = ConfigDict(extra="ignore")
45
+
46
+ id: str = Field(min_length=1, max_length=256)
47
+ content: str = Field(max_length=100_000)
48
+ metadata: dict[str, str] = Field(default_factory=dict)
49
+ tenant_id: str | None = None
50
+ user_id: str | None = None
51
+ session_id: str | None = None
52
+ active: bool | None = None
53
+
54
+ def to_record(self) -> MemoryRecord:
55
+ return MemoryRecord(**self.model_dump())
56
+
57
+
58
+ class HttpAdapter(MemoryAdapter):
59
+ name: ClassVar[str] = "http"
60
+ version: ClassVar[str] = "1.0"
61
+
62
+ def __init__(self, config: HttpAdapterConfig, *, environment: str) -> None:
63
+ policy = UrlPolicy(
64
+ allow_hosts=tuple(config.allow_hosts),
65
+ allow_loopback=config.allow_loopback,
66
+ allow_private_network=config.allow_private_network,
67
+ )
68
+ checked = check_url(config.base_url, policy)
69
+ if not checked.is_loopback and not config.authorization_confirmed:
70
+ raise UnsafeTargetError(
71
+ f"Remote target {checked.host} requires explicit authorization.",
72
+ hint=(
73
+ "Set adapter.authorization_confirmed: true to confirm you are authorized "
74
+ "to write and delete synthetic records on this system."
75
+ ),
76
+ )
77
+ if not checked.is_loopback and environment == "local":
78
+ raise ConfigError("execution.environment is 'local' but the target is remote.")
79
+ self._config = config
80
+ self._base = checked.url
81
+ self._host = checked.host
82
+ headers = {"User-Agent": "memtru", "Accept": "application/json"}
83
+ if config.token_env:
84
+ token = os.environ.get(config.token_env)
85
+ if not token:
86
+ raise ConfigError(
87
+ f"Environment variable {config.token_env} is not set.",
88
+ hint="adapter.token_env names the variable that holds the provider token.",
89
+ )
90
+ headers["Authorization"] = f"Bearer {token}"
91
+ self._client = httpx.Client(
92
+ base_url=self._base,
93
+ headers=headers,
94
+ timeout=config.timeout_seconds,
95
+ follow_redirects=False,
96
+ )
97
+ self._caps: frozenset[Capability] | None = None
98
+
99
+ # -- transport ---------------------------------------------------------------
100
+
101
+ @staticmethod
102
+ def _ctx_headers(ctx: MemoryContext) -> dict[str, str]:
103
+ headers = {
104
+ "X-Memtru-Tenant": ctx.tenant_id,
105
+ "X-Memtru-User": ctx.user_id,
106
+ "X-Memtru-Namespace": ctx.namespace,
107
+ "X-Memtru-Run": ctx.run_id,
108
+ }
109
+ if ctx.session_id:
110
+ headers["X-Memtru-Session"] = ctx.session_id
111
+ return headers
112
+
113
+ def _request(
114
+ self,
115
+ method: str,
116
+ path: str,
117
+ ctx: MemoryContext | None = None,
118
+ *,
119
+ params: dict[str, Any] | None = None,
120
+ payload: Any = None,
121
+ allow_404: bool = False,
122
+ ) -> Any:
123
+ headers = self._ctx_headers(ctx) if ctx else {}
124
+ attempt = 0
125
+ while True:
126
+ attempt += 1
127
+ try:
128
+ with self._client.stream(
129
+ method, path, params=params, json=payload, headers=headers
130
+ ) as response:
131
+ body = b""
132
+ for chunk in response.iter_bytes():
133
+ body += chunk
134
+ if len(body) > MAX_RESPONSE_BYTES:
135
+ raise AdapterError(
136
+ f"{method} {path}: response exceeded {MAX_RESPONSE_BYTES} bytes"
137
+ )
138
+ status = response.status_code
139
+ content_type = response.headers.get("content-type", "")
140
+ except httpx.TimeoutException:
141
+ error: AdapterError = TransientAdapterError(f"{method} {path}: request timed out")
142
+ except httpx.TransportError as err:
143
+ error = TransientAdapterError(f"{method} {path}: {type(err).__name__}")
144
+ else:
145
+ if status in {429, 502, 503, 504}:
146
+ error = TransientAdapterError(f"{method} {path}: HTTP {status}")
147
+ elif 300 <= status < 400:
148
+ raise AdapterError(
149
+ f"{method} {path}: unexpected redirect (HTTP {status}); redirects are "
150
+ "not followed"
151
+ )
152
+ elif status == 404 and allow_404:
153
+ return None
154
+ elif status >= 400:
155
+ raise AdapterError(f"{method} {path}: HTTP {status}")
156
+ elif status == 204 or not body:
157
+ return None
158
+ elif "json" not in content_type:
159
+ raise AdapterError(f"{method} {path}: expected JSON, got {content_type!r}")
160
+ else:
161
+ try:
162
+ return json.loads(body)
163
+ except ValueError:
164
+ raise AdapterError(f"{method} {path}: invalid JSON") from None
165
+ if attempt > self._config.max_retries:
166
+ raise error
167
+ time.sleep(min(0.25 * 2 ** (attempt - 1), 2.0))
168
+
169
+ @staticmethod
170
+ def _parse(data: Any, what: str) -> MemoryRecord:
171
+ try:
172
+ return _RecordModel.model_validate(data).to_record()
173
+ except ValidationError:
174
+ raise AdapterError(f"{what}: response is not a valid memory record") from None
175
+
176
+ # -- contract ----------------------------------------------------------------
177
+
178
+ def capabilities(self) -> frozenset[Capability]:
179
+ if self._caps is None:
180
+ names = self._config.capabilities
181
+ if names is None:
182
+ data = self._request("GET", "/capabilities")
183
+ if not isinstance(data, dict) or not isinstance(data.get("capabilities"), list):
184
+ raise AdapterError("GET /capabilities: expected {'capabilities': [...]}")
185
+ names = [str(n) for n in data["capabilities"]]
186
+ known = {c.value for c in Capability}
187
+ self._caps = frozenset(Capability(n) for n in names if n in known)
188
+ return self._caps
189
+
190
+ def describe(self) -> dict[str, str]:
191
+ return {"host": self._host}
192
+
193
+ def add(
194
+ self,
195
+ ctx: MemoryContext,
196
+ content: str,
197
+ *,
198
+ metadata: dict[str, str] | None = None,
199
+ scope: MemoryScope = "user",
200
+ ) -> MemoryRecord:
201
+ data = self._request(
202
+ "POST",
203
+ "/memories",
204
+ ctx,
205
+ payload={"content": content, "metadata": metadata or {}, "scope": scope},
206
+ )
207
+ return self._parse(data, "POST /memories")
208
+
209
+ def search(self, ctx: MemoryContext, query: str, *, limit: int = 10) -> list[MemoryRecord]:
210
+ data = self._request("GET", "/memories/search", ctx, params={"q": query, "limit": limit})
211
+ if not isinstance(data, dict) or not isinstance(data.get("results"), list):
212
+ raise AdapterError("GET /memories/search: expected {'results': [...]}")
213
+ return [self._parse(item, "search result") for item in data["results"][:limit]]
214
+
215
+ def get(self, ctx: MemoryContext, memory_id: str) -> MemoryRecord | None:
216
+ data = self._request("GET", f"/memories/{quote(memory_id, safe='')}", ctx, allow_404=True)
217
+ return None if data is None else self._parse(data, "GET /memories/{id}")
218
+
219
+ def update(
220
+ self,
221
+ ctx: MemoryContext,
222
+ memory_id: str,
223
+ content: str,
224
+ *,
225
+ metadata: dict[str, str] | None = None,
226
+ ) -> MemoryRecord:
227
+ data = self._request(
228
+ "PATCH",
229
+ f"/memories/{quote(memory_id, safe='')}",
230
+ ctx,
231
+ payload={"content": content, "metadata": metadata or {}},
232
+ )
233
+ return self._parse(data, "PATCH /memories/{id}")
234
+
235
+ def delete(self, ctx: MemoryContext, memory_id: str) -> None:
236
+ self._request("DELETE", f"/memories/{quote(memory_id, safe='')}", ctx)
237
+
238
+ def close(self) -> None:
239
+ self._client.close()