memmachine-client 0.3.7__tar.gz → 0.3.9__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.
- {memmachine_client-0.3.7 → memmachine_client-0.3.9}/PKG-INFO +15 -11
- {memmachine_client-0.3.7 → memmachine_client-0.3.9}/README.md +14 -10
- memmachine_client-0.3.9/client_tests/test_cli.py +228 -0
- {memmachine_client-0.3.7 → memmachine_client-0.3.9}/client_tests/test_client.py +91 -0
- {memmachine_client-0.3.7 → memmachine_client-0.3.9}/client_tests/test_config.py +28 -0
- {memmachine_client-0.3.7 → memmachine_client-0.3.9}/client_tests/test_format.py +79 -0
- {memmachine_client-0.3.7 → memmachine_client-0.3.9}/client_tests/test_langgraph.py +41 -5
- {memmachine_client-0.3.7 → memmachine_client-0.3.9}/client_tests/test_memory.py +48 -0
- {memmachine_client-0.3.7 → memmachine_client-0.3.9}/pyproject.toml +4 -0
- memmachine_client-0.3.9/src/memmachine_client/cli.py +478 -0
- {memmachine_client-0.3.7 → memmachine_client-0.3.9}/src/memmachine_client/client.py +86 -12
- {memmachine_client-0.3.7 → memmachine_client-0.3.9}/src/memmachine_client/config.py +21 -0
- {memmachine_client-0.3.7 → memmachine_client-0.3.9}/src/memmachine_client/format.py +3 -3
- {memmachine_client-0.3.7 → memmachine_client-0.3.9}/src/memmachine_client/langgraph.py +21 -2
- {memmachine_client-0.3.7 → memmachine_client-0.3.9}/src/memmachine_client/memory.py +31 -8
- {memmachine_client-0.3.7 → memmachine_client-0.3.9}/src/memmachine_client/project.py +11 -1
- {memmachine_client-0.3.7 → memmachine_client-0.3.9}/src/memmachine_client.egg-info/PKG-INFO +15 -11
- {memmachine_client-0.3.7 → memmachine_client-0.3.9}/src/memmachine_client.egg-info/SOURCES.txt +3 -0
- memmachine_client-0.3.9/src/memmachine_client.egg-info/entry_points.txt +3 -0
- {memmachine_client-0.3.7 → memmachine_client-0.3.9}/LICENSE +0 -0
- {memmachine_client-0.3.7 → memmachine_client-0.3.9}/client_tests/__init__.py +0 -0
- {memmachine_client-0.3.7 → memmachine_client-0.3.9}/client_tests/test_integration_complete.py +0 -0
- {memmachine_client-0.3.7 → memmachine_client-0.3.9}/setup.cfg +0 -0
- {memmachine_client-0.3.7 → memmachine_client-0.3.9}/src/memmachine_client/__init__.py +0 -0
- {memmachine_client-0.3.7 → memmachine_client-0.3.9}/src/memmachine_client.egg-info/dependency_links.txt +0 -0
- {memmachine_client-0.3.7 → memmachine_client-0.3.9}/src/memmachine_client.egg-info/requires.txt +0 -0
- {memmachine_client-0.3.7 → memmachine_client-0.3.9}/src/memmachine_client.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memmachine-client
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.9
|
|
4
4
|
Summary: MemMachine REST Client - A lightweight Python client library for MemMachine memory system
|
|
5
5
|
Author-email: MemMachine <noreply@memmachine.ai>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -39,11 +39,15 @@ A Python client library for the MemMachine memory system.
|
|
|
39
39
|
## Installation
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
pip install memmachine-client
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Development installation
|
|
46
|
+
|
|
47
|
+
If you are working on the package locally inside the MemMachine monorepo, install it from source instead:
|
|
44
48
|
|
|
45
|
-
|
|
46
|
-
pip install
|
|
49
|
+
```bash
|
|
50
|
+
pip install -e packages/client
|
|
47
51
|
```
|
|
48
52
|
|
|
49
53
|
## Quick Start
|
|
@@ -130,14 +134,14 @@ memory = client.memory(
|
|
|
130
134
|
memory.add("I like pizza", metadata={"type": "preference", "category": "food"})
|
|
131
135
|
memory.add("I work as a software engineer", metadata={"type": "fact", "category": "work"})
|
|
132
136
|
|
|
133
|
-
# Search memories
|
|
137
|
+
# Search memories — Memory.search() returns a SearchResult Pydantic model
|
|
134
138
|
results = memory.search("What do I like to eat?")
|
|
135
|
-
print(f"Episodic memory: {results.
|
|
136
|
-
print(f"
|
|
139
|
+
print(f"Episodic memory: {results.content.episodic_memory}")
|
|
140
|
+
print(f"Semantic memory: {results.content.semantic_memory}")
|
|
137
141
|
|
|
138
|
-
# Search with filters
|
|
139
|
-
work_results = memory.search("Tell me about work", filter_dict={"category": "work"})
|
|
140
|
-
print(f"Work results: {work_results}")
|
|
142
|
+
# Search with filters (user metadata fields require the `m.` / `metadata.` prefix)
|
|
143
|
+
work_results = memory.search("Tell me about work", filter_dict={"m.category": "work"})
|
|
144
|
+
print(f"Work results: {work_results.model_dump()}")
|
|
141
145
|
```
|
|
142
146
|
|
|
143
147
|
### Multiple Users
|
|
@@ -13,11 +13,15 @@ A Python client library for the MemMachine memory system.
|
|
|
13
13
|
## Installation
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
pip install memmachine-client
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Development installation
|
|
20
|
+
|
|
21
|
+
If you are working on the package locally inside the MemMachine monorepo, install it from source instead:
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
pip install
|
|
23
|
+
```bash
|
|
24
|
+
pip install -e packages/client
|
|
21
25
|
```
|
|
22
26
|
|
|
23
27
|
## Quick Start
|
|
@@ -104,14 +108,14 @@ memory = client.memory(
|
|
|
104
108
|
memory.add("I like pizza", metadata={"type": "preference", "category": "food"})
|
|
105
109
|
memory.add("I work as a software engineer", metadata={"type": "fact", "category": "work"})
|
|
106
110
|
|
|
107
|
-
# Search memories
|
|
111
|
+
# Search memories — Memory.search() returns a SearchResult Pydantic model
|
|
108
112
|
results = memory.search("What do I like to eat?")
|
|
109
|
-
print(f"Episodic memory: {results.
|
|
110
|
-
print(f"
|
|
113
|
+
print(f"Episodic memory: {results.content.episodic_memory}")
|
|
114
|
+
print(f"Semantic memory: {results.content.semantic_memory}")
|
|
111
115
|
|
|
112
|
-
# Search with filters
|
|
113
|
-
work_results = memory.search("Tell me about work", filter_dict={"category": "work"})
|
|
114
|
-
print(f"Work results: {work_results}")
|
|
116
|
+
# Search with filters (user metadata fields require the `m.` / `metadata.` prefix)
|
|
117
|
+
work_results = memory.search("Tell me about work", filter_dict={"m.category": "work"})
|
|
118
|
+
print(f"Work results: {work_results.model_dump()}")
|
|
115
119
|
```
|
|
116
120
|
|
|
117
121
|
### Multiple Users
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Tests for the MemMachine command line client."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest.mock import Mock, patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
if sys.version_info >= (3, 11):
|
|
11
|
+
import tomllib
|
|
12
|
+
else:
|
|
13
|
+
import tomli as tomllib
|
|
14
|
+
|
|
15
|
+
from memmachine_client import cli
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CliResult(BaseModel):
|
|
19
|
+
"""Small Pydantic test result."""
|
|
20
|
+
|
|
21
|
+
uid: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_pyproject_installs_command_name():
|
|
25
|
+
"""Editable installs should expose the documented mem-cli command."""
|
|
26
|
+
pyproject = tomllib.loads(
|
|
27
|
+
(Path(__file__).resolve().parents[1] / "pyproject.toml").read_text()
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
assert pyproject["project"]["scripts"]["mem-cli"] == ("memmachine_client.cli:main")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_build_client_uses_args_over_environment(monkeypatch):
|
|
34
|
+
"""Command arguments should override environment variables."""
|
|
35
|
+
monkeypatch.setenv("MEMORY_BACKEND_URL", "http://env:8080")
|
|
36
|
+
monkeypatch.setenv("MEMMACHINE_API_KEY", "env-key")
|
|
37
|
+
monkeypatch.setenv("MEMMACHINE_TIMEOUT", "10")
|
|
38
|
+
monkeypatch.setenv("MEMMACHINE_MAX_RETRIES", "2")
|
|
39
|
+
|
|
40
|
+
args = cli.build_parser().parse_args(
|
|
41
|
+
[
|
|
42
|
+
"--base-url",
|
|
43
|
+
"http://arg:8080",
|
|
44
|
+
"--api-key",
|
|
45
|
+
"arg-key",
|
|
46
|
+
"--timeout",
|
|
47
|
+
"20",
|
|
48
|
+
"--max-retries",
|
|
49
|
+
"4",
|
|
50
|
+
"health",
|
|
51
|
+
]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
client = cli.build_client(args)
|
|
55
|
+
|
|
56
|
+
assert client.base_url == "http://arg:8080"
|
|
57
|
+
assert client.api_key == "arg-key"
|
|
58
|
+
assert client.timeout == 20
|
|
59
|
+
assert client.max_retries == 4
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_build_client_requires_base_url(monkeypatch):
|
|
63
|
+
"""The CLI should fail clearly when base_url is unavailable."""
|
|
64
|
+
monkeypatch.delenv("MEMORY_BACKEND_URL", raising=False)
|
|
65
|
+
|
|
66
|
+
args = cli.build_parser().parse_args(["health"])
|
|
67
|
+
|
|
68
|
+
with pytest.raises(SystemExit) as exc:
|
|
69
|
+
cli.build_client(args)
|
|
70
|
+
|
|
71
|
+
assert exc.value.code == 2
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_parser_help_uses_installed_command_name(capsys):
|
|
75
|
+
"""Help output should match the documented console script."""
|
|
76
|
+
with pytest.raises(SystemExit) as exc:
|
|
77
|
+
cli.build_parser().parse_args(["--help"])
|
|
78
|
+
|
|
79
|
+
assert exc.value.code == 0
|
|
80
|
+
assert capsys.readouterr().out.startswith("usage: mem-cli ")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_custom_errors_use_parser_program_name(monkeypatch, capsys):
|
|
84
|
+
"""Custom errors should use the same program name as argparse."""
|
|
85
|
+
monkeypatch.delenv("MEMORY_BACKEND_URL", raising=False)
|
|
86
|
+
|
|
87
|
+
args = cli.build_parser().parse_args(["health"])
|
|
88
|
+
|
|
89
|
+
with pytest.raises(SystemExit) as exc:
|
|
90
|
+
cli.build_client(args)
|
|
91
|
+
|
|
92
|
+
assert exc.value.code == 2
|
|
93
|
+
assert capsys.readouterr().err == (
|
|
94
|
+
"mem-cli: error: --base-url or MEMORY_BACKEND_URL is required\n"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_health_command_prints_json(capsys):
|
|
99
|
+
"""The health command should call the underlying client."""
|
|
100
|
+
client = Mock()
|
|
101
|
+
client.health_check.return_value = {"status": "healthy"}
|
|
102
|
+
|
|
103
|
+
result = cli.run_command(client, cli.build_parser().parse_args(["health"]))
|
|
104
|
+
|
|
105
|
+
assert result == 0
|
|
106
|
+
client.health_check.assert_called_once_with(timeout=None)
|
|
107
|
+
assert capsys.readouterr().out == '{\n "status": "healthy"\n}\n'
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_memory_add_uses_env_project_context(monkeypatch, capsys):
|
|
111
|
+
"""Memory commands should accept project context from the environment."""
|
|
112
|
+
monkeypatch.setenv("MEMMACHINE_ORG_ID", "env-org")
|
|
113
|
+
monkeypatch.setenv("MEMMACHINE_PROJECT_ID", "env-project")
|
|
114
|
+
client = Mock()
|
|
115
|
+
project = Mock()
|
|
116
|
+
memory = Mock()
|
|
117
|
+
memory.add.return_value = [CliResult(uid="mem-1")]
|
|
118
|
+
project.memory.return_value = memory
|
|
119
|
+
client.get_project.return_value = project
|
|
120
|
+
|
|
121
|
+
args = cli.build_parser().parse_args(
|
|
122
|
+
["memory", "add", "hello", "--metadata", "user_id=user-1"]
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
exit_code = cli.run_command(client, args)
|
|
126
|
+
|
|
127
|
+
assert exit_code == 0
|
|
128
|
+
client.get_project.assert_called_once_with(
|
|
129
|
+
org_id="env-org",
|
|
130
|
+
project_id="env-project",
|
|
131
|
+
timeout=None,
|
|
132
|
+
)
|
|
133
|
+
project.memory.assert_called_once_with(metadata={"user_id": "user-1"})
|
|
134
|
+
memory.add.assert_called_once_with(
|
|
135
|
+
"hello",
|
|
136
|
+
role="",
|
|
137
|
+
producer=None,
|
|
138
|
+
produced_for=None,
|
|
139
|
+
metadata=None,
|
|
140
|
+
timeout=None,
|
|
141
|
+
)
|
|
142
|
+
assert capsys.readouterr().out == '[\n {\n "uid": "mem-1"\n }\n]\n'
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@pytest.mark.parametrize(
|
|
146
|
+
("argv", "memory_method"),
|
|
147
|
+
[
|
|
148
|
+
(["memory", "search", "hello"], "search"),
|
|
149
|
+
(["memory", "list"], "list"),
|
|
150
|
+
(["memory", "delete-episodic", "--id", "episode-1"], "delete_episodic"),
|
|
151
|
+
(["memory", "delete-semantic", "--id", "semantic-1"], "delete_semantic"),
|
|
152
|
+
],
|
|
153
|
+
)
|
|
154
|
+
def test_memory_subcommands_use_env_project_context(
|
|
155
|
+
argv, memory_method, monkeypatch, capsys
|
|
156
|
+
):
|
|
157
|
+
"""Memory subcommands should resolve project context through the shared helper."""
|
|
158
|
+
monkeypatch.setenv("MEMMACHINE_ORG_ID", "env-org")
|
|
159
|
+
monkeypatch.setenv("MEMMACHINE_PROJECT_ID", "env-project")
|
|
160
|
+
client = Mock()
|
|
161
|
+
project = Mock()
|
|
162
|
+
memory = Mock()
|
|
163
|
+
getattr(memory, memory_method).return_value = (
|
|
164
|
+
True if memory_method.startswith("delete_") else []
|
|
165
|
+
)
|
|
166
|
+
project.memory.return_value = memory
|
|
167
|
+
client.get_project.return_value = project
|
|
168
|
+
|
|
169
|
+
args = cli.build_parser().parse_args(argv)
|
|
170
|
+
|
|
171
|
+
assert cli.run_command(client, args) == 0
|
|
172
|
+
client.get_project.assert_called_once_with(
|
|
173
|
+
org_id="env-org",
|
|
174
|
+
project_id="env-project",
|
|
175
|
+
timeout=None,
|
|
176
|
+
)
|
|
177
|
+
project.memory.assert_called_once_with(metadata={})
|
|
178
|
+
assert capsys.readouterr().out
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_projects_create_uses_env_project_context(monkeypatch, capsys):
|
|
182
|
+
"""Project create should accept project context from the environment."""
|
|
183
|
+
monkeypatch.setenv("MEMMACHINE_ORG_ID", "env-org")
|
|
184
|
+
monkeypatch.setenv("MEMMACHINE_PROJECT_ID", "env-project")
|
|
185
|
+
client = Mock()
|
|
186
|
+
project = Mock(
|
|
187
|
+
org_id="env-org",
|
|
188
|
+
project_id="env-project",
|
|
189
|
+
description="",
|
|
190
|
+
config={},
|
|
191
|
+
)
|
|
192
|
+
client.create_project.return_value = project
|
|
193
|
+
|
|
194
|
+
args = cli.build_parser().parse_args(["projects", "create"])
|
|
195
|
+
|
|
196
|
+
exit_code = cli.run_command(client, args)
|
|
197
|
+
|
|
198
|
+
assert exit_code == 0
|
|
199
|
+
client.create_project.assert_called_once_with(
|
|
200
|
+
org_id="env-org",
|
|
201
|
+
project_id="env-project",
|
|
202
|
+
description="",
|
|
203
|
+
embedder="",
|
|
204
|
+
reranker="",
|
|
205
|
+
timeout=None,
|
|
206
|
+
)
|
|
207
|
+
assert capsys.readouterr().out == (
|
|
208
|
+
"{\n"
|
|
209
|
+
' "config": {},\n'
|
|
210
|
+
' "description": "",\n'
|
|
211
|
+
' "org_id": "env-org",\n'
|
|
212
|
+
' "project_id": "env-project"\n'
|
|
213
|
+
"}\n"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@patch("memmachine_client.cli.MemMachineClient")
|
|
218
|
+
def test_main_closes_client(mock_client_class, monkeypatch):
|
|
219
|
+
"""The top-level entry point should close the client after command execution."""
|
|
220
|
+
monkeypatch.setenv("MEMORY_BACKEND_URL", "http://localhost:8080")
|
|
221
|
+
client = Mock()
|
|
222
|
+
client.health_check.return_value = {"status": "healthy"}
|
|
223
|
+
mock_client_class.return_value = client
|
|
224
|
+
|
|
225
|
+
exit_code = cli.main(["health"])
|
|
226
|
+
|
|
227
|
+
assert exit_code == 0
|
|
228
|
+
client.close.assert_called_once_with()
|
|
@@ -206,6 +206,97 @@ class TestMemMachineClient:
|
|
|
206
206
|
project_id="test_project",
|
|
207
207
|
)
|
|
208
208
|
|
|
209
|
+
def test_create_project_event_backend_fields_reach_wire(self):
|
|
210
|
+
"""Event-backend fields must reach the wire — without this the SDK
|
|
211
|
+
has no supported path to event-backed projects."""
|
|
212
|
+
client = MemMachineClient(base_url="http://localhost:8080")
|
|
213
|
+
mock_response = Mock()
|
|
214
|
+
mock_response.status_code = 200
|
|
215
|
+
mock_response.raise_for_status = Mock()
|
|
216
|
+
mock_response.json.return_value = {
|
|
217
|
+
"org_id": "test_org",
|
|
218
|
+
"project_id": "test_project",
|
|
219
|
+
"description": "",
|
|
220
|
+
"config": {
|
|
221
|
+
"backend": "event",
|
|
222
|
+
"embedder": "default",
|
|
223
|
+
"reranker": "default",
|
|
224
|
+
"vector_store": "qdrant_vs",
|
|
225
|
+
"segment_store": "sqlite_db",
|
|
226
|
+
"properties_schema": {"customer_tier": "str"},
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
with patch.object(
|
|
230
|
+
client._session, "post", return_value=mock_response
|
|
231
|
+
) as mock_post:
|
|
232
|
+
client.create_project(
|
|
233
|
+
org_id="test_org",
|
|
234
|
+
project_id="test_project",
|
|
235
|
+
backend="event",
|
|
236
|
+
vector_store="qdrant_vs",
|
|
237
|
+
segment_store="sqlite_db",
|
|
238
|
+
properties_schema={"customer_tier": "str"},
|
|
239
|
+
)
|
|
240
|
+
cfg = mock_post.call_args[1]["json"]["config"]
|
|
241
|
+
assert cfg["backend"] == "event"
|
|
242
|
+
assert cfg["vector_store"] == "qdrant_vs"
|
|
243
|
+
assert cfg["segment_store"] == "sqlite_db"
|
|
244
|
+
assert cfg["properties_schema"] == {"customer_tier": "str"}
|
|
245
|
+
|
|
246
|
+
def test_get_project_404_raises_http_error(self):
|
|
247
|
+
"""Test missing project preserves HTTPError semantics."""
|
|
248
|
+
client = MemMachineClient(base_url="http://localhost:8080")
|
|
249
|
+
mock_response = Mock()
|
|
250
|
+
mock_response.status_code = 404
|
|
251
|
+
http_error = requests.HTTPError(response=mock_response)
|
|
252
|
+
mock_response.raise_for_status.side_effect = http_error
|
|
253
|
+
|
|
254
|
+
with (
|
|
255
|
+
patch.object(client, "request", return_value=mock_response),
|
|
256
|
+
pytest.raises(requests.HTTPError) as exc_info,
|
|
257
|
+
):
|
|
258
|
+
client.get_project(org_id="test_org", project_id="missing_project")
|
|
259
|
+
|
|
260
|
+
assert exc_info.value is http_error
|
|
261
|
+
assert exc_info.value.response.status_code == 404
|
|
262
|
+
|
|
263
|
+
def test_get_or_create_project_creates_after_get_404(self):
|
|
264
|
+
"""Test missing project is created by get_or_create_project."""
|
|
265
|
+
client = MemMachineClient(base_url="http://localhost:8080")
|
|
266
|
+
get_response = Mock()
|
|
267
|
+
get_response.status_code = 404
|
|
268
|
+
get_response.raise_for_status.side_effect = requests.HTTPError(
|
|
269
|
+
response=get_response
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
create_response = Mock()
|
|
273
|
+
create_response.status_code = 200
|
|
274
|
+
create_response.raise_for_status = Mock()
|
|
275
|
+
create_response.json.return_value = {
|
|
276
|
+
"org_id": "test_org",
|
|
277
|
+
"project_id": "new_project",
|
|
278
|
+
"description": "Created project",
|
|
279
|
+
"config": {"embedder": "", "reranker": ""},
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
with (
|
|
283
|
+
patch.object(client, "request", return_value=get_response) as mock_request,
|
|
284
|
+
patch.object(
|
|
285
|
+
client._session, "post", return_value=create_response
|
|
286
|
+
) as mock_post,
|
|
287
|
+
):
|
|
288
|
+
project = client.get_or_create_project(
|
|
289
|
+
org_id="test_org",
|
|
290
|
+
project_id="new_project",
|
|
291
|
+
description="Created project",
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
assert project.org_id == "test_org"
|
|
295
|
+
assert project.project_id == "new_project"
|
|
296
|
+
assert project.description == "Created project"
|
|
297
|
+
mock_request.assert_called_once()
|
|
298
|
+
mock_post.assert_called_once()
|
|
299
|
+
|
|
209
300
|
@patch("requests.Session.get")
|
|
210
301
|
def test_health_check_success(self, mock_get):
|
|
211
302
|
"""Test successful health check."""
|
|
@@ -368,6 +368,30 @@ class TestConfig:
|
|
|
368
368
|
with pytest.raises(RuntimeError, match="client has been closed"):
|
|
369
369
|
config.update_long_term_memory_config(embedder="x")
|
|
370
370
|
|
|
371
|
+
def test_update_long_term_memory_config_event_backend(self, config, mock_client):
|
|
372
|
+
"""Event-backend fields (backend / vector_store / segment_store /
|
|
373
|
+
properties_schema) must reach the wire — without these the SDK has no
|
|
374
|
+
supported path to event-backed projects."""
|
|
375
|
+
mock_client.request.return_value = _mock_response(
|
|
376
|
+
{"success": True, "message": "Long-term memory configuration updated"}
|
|
377
|
+
)
|
|
378
|
+
result = config.update_long_term_memory_config(
|
|
379
|
+
backend="event",
|
|
380
|
+
embedder="new-embedder",
|
|
381
|
+
vector_store="qdrant_vs",
|
|
382
|
+
segment_store="sqlite_db",
|
|
383
|
+
properties_schema={"customer_tier": "str"},
|
|
384
|
+
)
|
|
385
|
+
assert isinstance(result, UpdateMemoryConfigResponse)
|
|
386
|
+
body = mock_client.request.call_args[1]["json"]
|
|
387
|
+
assert body["backend"] == "event"
|
|
388
|
+
assert body["vector_store"] == "qdrant_vs"
|
|
389
|
+
assert body["segment_store"] == "sqlite_db"
|
|
390
|
+
assert body["properties_schema"] == {"customer_tier": "str"}
|
|
391
|
+
assert body["embedder"] == "new-embedder"
|
|
392
|
+
# Untouched declarative-only field must NOT leak into the payload.
|
|
393
|
+
assert "vector_graph_store" not in body
|
|
394
|
+
|
|
371
395
|
def test_update_short_term_memory_config(self, config, mock_client):
|
|
372
396
|
mock_client.request.return_value = _mock_response(
|
|
373
397
|
{"success": True, "message": "Short-term memory configuration updated"}
|
|
@@ -533,9 +557,13 @@ class TestConfig:
|
|
|
533
557
|
{"success": True, "message": "Episodic memory configuration updated"}
|
|
534
558
|
)
|
|
535
559
|
ltm_spec = UpdateLongTermMemorySpec(
|
|
560
|
+
backend=None,
|
|
536
561
|
embedder="new-embedder",
|
|
537
562
|
reranker="new-reranker",
|
|
538
563
|
vector_graph_store="new-store",
|
|
564
|
+
vector_store=None,
|
|
565
|
+
segment_store=None,
|
|
566
|
+
properties_schema=None,
|
|
539
567
|
)
|
|
540
568
|
stm_spec = UpdateShortTermMemorySpec(
|
|
541
569
|
llm_model="new-llm",
|
|
@@ -99,6 +99,59 @@ class TestFormatEpisodes:
|
|
|
99
99
|
result = format_episodes([ep])
|
|
100
100
|
assert json.dumps('She said "hello"') in result
|
|
101
101
|
|
|
102
|
+
def test_non_ascii_content_preserved_literally(self):
|
|
103
|
+
"""Non-ASCII characters must appear literally in the LLM context, not
|
|
104
|
+
as ``\\uXXXX`` escapes — escaping bloats token counts and degrades
|
|
105
|
+
recall on multilingual content."""
|
|
106
|
+
ep = EpisodeResponse(
|
|
107
|
+
uid="1",
|
|
108
|
+
content="寿司 café 🍕 naïve résumé Привет",
|
|
109
|
+
producer_id="user_1",
|
|
110
|
+
producer_role="user",
|
|
111
|
+
created_at=datetime(2024, 1, 1, 0, 0, tzinfo=timezone.utc),
|
|
112
|
+
)
|
|
113
|
+
result = format_episodes([ep])
|
|
114
|
+
assert "寿司" in result
|
|
115
|
+
assert "café" in result
|
|
116
|
+
assert "🍕" in result
|
|
117
|
+
assert "naïve" in result
|
|
118
|
+
assert "résumé" in result
|
|
119
|
+
assert "Привет" in result
|
|
120
|
+
# Sanity: no escaped CJK / cyrillic / accented sequences.
|
|
121
|
+
assert "\\u" not in result
|
|
122
|
+
|
|
123
|
+
def test_non_ascii_content_lossless_roundtrip(self):
|
|
124
|
+
"""The JSON-encoded portion must round-trip back to the original
|
|
125
|
+
string so downstream LLM consumers (and any client-side
|
|
126
|
+
post-processing) still see correct text."""
|
|
127
|
+
original = '日本語 — "quoted" + emoji 🎉'
|
|
128
|
+
ep = EpisodeResponse(
|
|
129
|
+
uid="1",
|
|
130
|
+
content=original,
|
|
131
|
+
producer_id="user_1",
|
|
132
|
+
producer_role="user",
|
|
133
|
+
created_at=None,
|
|
134
|
+
)
|
|
135
|
+
result = format_episodes([ep])
|
|
136
|
+
json_part = result.removeprefix("user_1: ").rstrip("\n")
|
|
137
|
+
assert json.loads(json_part) == original
|
|
138
|
+
|
|
139
|
+
def test_output_is_utf8_encodable(self):
|
|
140
|
+
"""The LLM-visible string must be safe to send over UTF-8 transports
|
|
141
|
+
(HTTP body, logging sinks). ``ensure_ascii=False`` produces
|
|
142
|
+
unescaped surrogates only for malformed inputs; clean Unicode must
|
|
143
|
+
encode without error."""
|
|
144
|
+
ep = EpisodeResponse(
|
|
145
|
+
uid="1",
|
|
146
|
+
content="Mixed: ASCII + 中文 + 🚀",
|
|
147
|
+
producer_id="user_1",
|
|
148
|
+
producer_role="user",
|
|
149
|
+
created_at=datetime(2024, 1, 1, 0, 0, tzinfo=timezone.utc),
|
|
150
|
+
)
|
|
151
|
+
result = format_episodes([ep])
|
|
152
|
+
encoded = result.encode("utf-8")
|
|
153
|
+
assert encoded.decode("utf-8") == result
|
|
154
|
+
|
|
102
155
|
|
|
103
156
|
class TestFormatSemanticMemories:
|
|
104
157
|
"""Tests for format_semantic_memories."""
|
|
@@ -146,6 +199,32 @@ class TestFormatSemanticMemories:
|
|
|
146
199
|
"background": {"role": "engineer"},
|
|
147
200
|
}
|
|
148
201
|
|
|
202
|
+
def test_non_ascii_value_preserved_literally(self):
|
|
203
|
+
feature = SemanticFeature(
|
|
204
|
+
category="profile",
|
|
205
|
+
tag="prefs",
|
|
206
|
+
feature_name="favorite_food",
|
|
207
|
+
value="寿司 🍣",
|
|
208
|
+
)
|
|
209
|
+
result = format_semantic_memories([feature])
|
|
210
|
+
assert "寿司" in result
|
|
211
|
+
assert "🍣" in result
|
|
212
|
+
assert "\\u" not in result
|
|
213
|
+
# And the JSON is still valid.
|
|
214
|
+
assert json.loads(result) == {"prefs": {"favorite_food": "寿司 🍣"}}
|
|
215
|
+
|
|
216
|
+
def test_non_ascii_tag_and_feature_name_preserved(self):
|
|
217
|
+
feature = SemanticFeature(
|
|
218
|
+
category="profile",
|
|
219
|
+
tag="préférences",
|
|
220
|
+
feature_name="種類",
|
|
221
|
+
value="ramen",
|
|
222
|
+
)
|
|
223
|
+
result = format_semantic_memories([feature])
|
|
224
|
+
assert "préférences" in result
|
|
225
|
+
assert "種類" in result
|
|
226
|
+
assert json.loads(result) == {"préférences": {"種類": "ramen"}}
|
|
227
|
+
|
|
149
228
|
def test_metadata_excluded(self):
|
|
150
229
|
feature = SemanticFeature(
|
|
151
230
|
set_id="set_1",
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from unittest.mock import Mock, patch
|
|
4
4
|
|
|
5
|
+
from memmachine_common.api import EpisodeType
|
|
6
|
+
|
|
5
7
|
from memmachine_client.langgraph import (
|
|
6
8
|
MemMachineTools,
|
|
7
9
|
create_add_memory_tool,
|
|
@@ -145,9 +147,43 @@ class TestAddMemory:
|
|
|
145
147
|
assert result["uids"] == ["uid-123"]
|
|
146
148
|
assert result["content"] == "Hello world"
|
|
147
149
|
mock_memory.add.assert_called_once_with(
|
|
148
|
-
content="Hello world", role="user", metadata={}
|
|
150
|
+
content="Hello world", role="user", metadata={}, episode_type=None
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def test_raw_filter_passthrough(self):
|
|
154
|
+
tools, mock_memory = self._make_tools()
|
|
155
|
+
|
|
156
|
+
mock_content = Mock()
|
|
157
|
+
mock_content.episodic_memory = None
|
|
158
|
+
mock_content.semantic_memory = []
|
|
159
|
+
|
|
160
|
+
mock_search_result = Mock()
|
|
161
|
+
mock_search_result.content = mock_content
|
|
162
|
+
mock_memory.search.return_value = mock_search_result
|
|
163
|
+
|
|
164
|
+
result = tools.search_memory(query="food", filter='metadata.category = "work"')
|
|
165
|
+
|
|
166
|
+
assert result["status"] == "success"
|
|
167
|
+
mock_memory.search.assert_called_once_with(
|
|
168
|
+
query="food",
|
|
169
|
+
limit=20,
|
|
170
|
+
score_threshold=None,
|
|
171
|
+
filter_dict=None,
|
|
172
|
+
filter='metadata.category = "work"',
|
|
149
173
|
)
|
|
150
174
|
|
|
175
|
+
def test_episode_type_string_is_normalized(self):
|
|
176
|
+
tools, mock_memory = self._make_tools()
|
|
177
|
+
mock_result = Mock()
|
|
178
|
+
mock_result.uid = "uid-ep"
|
|
179
|
+
mock_memory.add.return_value = [mock_result]
|
|
180
|
+
|
|
181
|
+
result = tools.add_memory(content="Hello world", episode_type="message")
|
|
182
|
+
|
|
183
|
+
assert result["status"] == "success"
|
|
184
|
+
mock_memory.add.assert_called_once()
|
|
185
|
+
assert mock_memory.add.call_args.kwargs["episode_type"] == EpisodeType.MESSAGE
|
|
186
|
+
|
|
151
187
|
def test_error_path(self):
|
|
152
188
|
tools, mock_memory = self._make_tools()
|
|
153
189
|
mock_memory.add.side_effect = RuntimeError("boom")
|
|
@@ -203,7 +239,7 @@ class TestSearchMemory:
|
|
|
203
239
|
assert result["results"]["episodic_memory"] == {"episodes": [{"content": "hi"}]}
|
|
204
240
|
assert result["results"]["semantic_memory"] == [{"name": "likes pizza"}]
|
|
205
241
|
mock_memory.search.assert_called_once_with(
|
|
206
|
-
query="food", limit=20, score_threshold=None, filter_dict=None
|
|
242
|
+
query="food", limit=20, score_threshold=None, filter_dict=None, filter=None
|
|
207
243
|
)
|
|
208
244
|
|
|
209
245
|
def test_error_path(self):
|
|
@@ -323,7 +359,7 @@ class TestFactoryFunctions:
|
|
|
323
359
|
mock_memory.add.return_value = [mock_result]
|
|
324
360
|
|
|
325
361
|
add_fn = create_add_memory_tool(tools)
|
|
326
|
-
result = add_fn("test content", None, None)
|
|
362
|
+
result = add_fn("test content", None, None, None)
|
|
327
363
|
|
|
328
364
|
assert result["status"] == "success"
|
|
329
365
|
assert result["uids"] == ["uid-1"]
|
|
@@ -335,7 +371,7 @@ class TestFactoryFunctions:
|
|
|
335
371
|
mock_memory.add.return_value = [mock_result]
|
|
336
372
|
|
|
337
373
|
add_fn = create_add_memory_tool(tools)
|
|
338
|
-
result = add_fn("content", "u1", None)
|
|
374
|
+
result = add_fn("content", "u1", None, "message")
|
|
339
375
|
|
|
340
376
|
assert result["status"] == "success"
|
|
341
377
|
|
|
@@ -368,5 +404,5 @@ class TestFactoryFunctions:
|
|
|
368
404
|
search_fn("hello", None, 10)
|
|
369
405
|
|
|
370
406
|
mock_memory.search.assert_called_once_with(
|
|
371
|
-
query="hello", limit=10, score_threshold=None, filter_dict=None
|
|
407
|
+
query="hello", limit=10, score_threshold=None, filter_dict=None, filter=None
|
|
372
408
|
)
|