@pentatonic-ai/ai-agent-sdk 0.7.13 → 0.8.1

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.
Files changed (26) hide show
  1. package/package.json +1 -1
  2. package/packages/memory/openclaw-plugin/index.js +7 -0
  3. package/packages/memory/openclaw-plugin/openclaw.plugin.json +9 -1
  4. package/packages/memory/openclaw-plugin/package.json +1 -1
  5. package/packages/memory/src/__tests__/engine.test.js +142 -0
  6. package/packages/memory/src/engine.js +65 -0
  7. package/packages/memory-engine/compat/server.py +90 -5
  8. package/packages/memory-engine/docker-compose.yml +18 -8
  9. package/packages/memory-engine/engine/services/_shared/__init__.py +1 -0
  10. package/packages/memory-engine/engine/services/_shared/embed_provider.py +431 -0
  11. package/packages/memory-engine/engine/services/l2/Dockerfile +4 -2
  12. package/packages/memory-engine/engine/services/l2/l2-hybridrag-proxy.py +640 -81
  13. package/packages/memory-engine/engine/services/l4/Dockerfile +5 -1
  14. package/packages/memory-engine/engine/services/l4/server.py +19 -57
  15. package/packages/memory-engine/engine/services/l5/Dockerfile +3 -1
  16. package/packages/memory-engine/engine/services/l5/l5-comms-layer.py +24 -32
  17. package/packages/memory-engine/engine/services/l6/Dockerfile +3 -1
  18. package/packages/memory-engine/engine/services/l6/l6-document-store.py +24 -29
  19. package/packages/memory-engine/scripts/wipe-legacy-l3-entities.py +128 -0
  20. package/packages/memory-engine/tests/e2e_arena.sh +28 -4
  21. package/packages/memory-engine/tests/test_aggregate.py +333 -0
  22. package/packages/memory-engine/tests/test_arena_safety.py +232 -0
  23. package/packages/memory-engine/tests/test_channel_stat_reader.py +437 -0
  24. package/packages/memory-engine/tests/test_channel_stat_rollups.py +308 -0
  25. package/packages/memory-engine/tests/test_embed_provider.py +354 -0
  26. package/packages/memory-engine/tests/test_l3_arena_isolation.py +412 -0
@@ -0,0 +1,412 @@
1
+ """Integration tests for L3 arena isolation + typed-entity writes.
2
+
3
+ Two flavours:
4
+
5
+ - Neo4j-backed integration tests (run when ``NEO4J_TEST_URI`` and
6
+ ``NEO4J_TEST_PASSWORD`` env vars are set). These spin up the actual
7
+ writer logic and verify cross-arena isolation against a live
8
+ Neo4j. Skip cleanly when env is absent so the unit-test job stays
9
+ Neo4j-free.
10
+
11
+ - Pure-unit tests using a stub session that records every Cypher
12
+ call. Fast, hermetic, validate the structural invariants we care
13
+ about: each typed-entity write carries arena, COMMUNICATED edges
14
+ carry channel + direction, etc.
15
+
16
+ Run:
17
+
18
+ cd packages/memory-engine
19
+ .venv/bin/python -m pytest tests/test_l3_arena_isolation.py -v
20
+
21
+ Run with Neo4j:
22
+
23
+ NEO4J_TEST_URI=bolt://localhost:7687 \\
24
+ NEO4J_TEST_PASSWORD=test \\
25
+ .venv/bin/python -m pytest tests/test_l3_arena_isolation.py -v
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import sys
31
+ import uuid
32
+ from pathlib import Path
33
+
34
+ import pytest
35
+
36
+
37
+ # Make the engine module importable without packaging it.
38
+ ENGINE_ROOT = Path(__file__).resolve().parent.parent / "engine" / "services" / "l2"
39
+ sys.path.insert(0, str(ENGINE_ROOT))
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Stub session for unit tests — records calls without hitting Neo4j.
44
+ # ---------------------------------------------------------------------------
45
+
46
+
47
+ class _Recorder:
48
+ """Stand-in for a Neo4j session.run that records every call.
49
+
50
+ Just enough surface for the writer block in the engine to think it's
51
+ talking to Neo4j: ``run(cypher, **params)`` returns an object whose
52
+ ``single()`` / iteration / ``data()`` calls all return empty.
53
+ """
54
+
55
+ def __init__(self) -> None:
56
+ self.calls: list[tuple[str, dict]] = []
57
+
58
+ def run(self, cypher: str, **params) -> "_Recorder":
59
+ self.calls.append((cypher, params))
60
+ return self
61
+
62
+ def single(self) -> dict:
63
+ return {}
64
+
65
+ def __iter__(self):
66
+ return iter([])
67
+
68
+
69
+ def _has_arena_in_pattern(cypher: str, label: str, var: str = "") -> bool:
70
+ """True if every occurrence of (…label…) in the cypher names arena.
71
+
72
+ Parses each node pattern as ``(var:Label1:Label2:… {props})``,
73
+ finds the ones whose label list contains the target ``label``, and
74
+ asserts each one is arena-scoped (either via ``arena:`` in the
75
+ property bag or via ``<var>.arena`` somewhere in the same block).
76
+
77
+ Parameters
78
+ ----------
79
+ cypher : the Cypher block to inspect.
80
+ label : target label (e.g. ``"Entity"``, ``"Chunk"``).
81
+ var : optional variable filter — when set, only patterns that
82
+ bind this variable are checked. Useful when a block has
83
+ multiple patterns of the same label and we want to assert
84
+ one specific one.
85
+ """
86
+ import re
87
+
88
+ pattern = re.compile(
89
+ r"\("
90
+ r"\s*(?P<v>[A-Za-z_]\w*)?"
91
+ r"\s*(?P<labels>(?::[A-Za-z_]\w*)+)" # one or more labels
92
+ r"\s*(?P<props>\{[^{}]*\})?"
93
+ r"\s*\)",
94
+ re.MULTILINE,
95
+ )
96
+ target = f":{label}"
97
+ found_any = False
98
+ for m in pattern.finditer(cypher):
99
+ labels = m.group("labels") or ""
100
+ # Require an exact-label match so :Entity matches `:Entity` and
101
+ # `:Entity:Concept` but not `:Entitlement` (re.search alone
102
+ # would treat the latter as a hit).
103
+ label_tokens = re.findall(r":([A-Za-z_]\w*)", labels)
104
+ if label not in label_tokens:
105
+ continue
106
+ v = m.group("v") or ""
107
+ if var and v != var:
108
+ continue
109
+ found_any = True
110
+ props = m.group("props") or ""
111
+ if "arena" in props:
112
+ continue
113
+ if v and re.search(rf"\b{re.escape(v)}\.arena\b", cypher):
114
+ continue
115
+ return False
116
+ return found_any
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Unit tests — exercise the writer block via the stub session by
121
+ # calling its Cypher directly.
122
+ # ---------------------------------------------------------------------------
123
+
124
+
125
+ def test_writer_concept_path_carries_arena() -> None:
126
+ """The Concept-extraction Cypher always names arena on Entity + Chunk."""
127
+ rec = _Recorder()
128
+ arena = "acme"
129
+ rec.run(
130
+ """
131
+ MERGE (e:Entity:Concept {arena: $arena, name: $name})
132
+ ON CREATE SET e.type = 'Concept',
133
+ e.created_at = $now,
134
+ e.weight = 1.0
135
+ WITH e
136
+ MATCH (c:Chunk {arena: $arena, id: $cid})
137
+ MERGE (e)-[r:MENTIONS]->(c)
138
+ ON CREATE SET r.weight = 1.0, r.created_at = $now
139
+ ON MATCH SET r.weight = coalesce(r.weight, 1.0) + 0.1
140
+ """,
141
+ arena=arena, name="Pricing", cid="chunk-1", now="t",
142
+ )
143
+ cypher, params = rec.calls[-1]
144
+ assert _has_arena_in_pattern(cypher, "Entity")
145
+ assert _has_arena_in_pattern(cypher, "Chunk")
146
+ assert params["arena"] == arena
147
+
148
+
149
+ def test_writer_person_email_carries_arena_email_and_communicated_edge() -> None:
150
+ """Metadata-driven Person email path tags arena + builds COMMUNICATED edge."""
151
+ rec = _Recorder()
152
+ arena = "acme"
153
+ rec.run(
154
+ """
155
+ MERGE (p:Entity:Person {arena: $arena, email: $email})
156
+ ON CREATE SET p.created_at = $now,
157
+ p.first_seen = $occurred_at,
158
+ p.last_seen = $occurred_at
159
+ ON MATCH SET p.last_seen = CASE
160
+ WHEN $occurred_at > coalesce(p.last_seen, '')
161
+ THEN $occurred_at
162
+ ELSE p.last_seen END
163
+ WITH p
164
+ MATCH (c:Chunk {arena: $arena, id: $cid})
165
+ MERGE (p)-[r:COMMUNICATED]->(c)
166
+ ON CREATE SET r.channel = $channel,
167
+ r.direction = $direction,
168
+ r.occurred_at = $occurred_at,
169
+ r.weight = 1.0
170
+ """,
171
+ arena=arena, email="alex@acme.com", cid="c-1",
172
+ channel="email", direction="inbound",
173
+ occurred_at="2026-05-08T00:00:00Z", now="t",
174
+ )
175
+ cypher, params = rec.calls[-1]
176
+ assert _has_arena_in_pattern(cypher, "Person")
177
+ assert _has_arena_in_pattern(cypher, "Chunk")
178
+ assert "COMMUNICATED" in cypher
179
+ assert params["channel"] == "email"
180
+ assert params["direction"] == "inbound"
181
+
182
+
183
+ def test_co_occurs_edges_are_arena_scoped_on_both_endpoints() -> None:
184
+ """CO_OCCURS Cypher matches both endpoints in the same arena."""
185
+ rec = _Recorder()
186
+ rec.run(
187
+ """
188
+ MATCH (a:Entity:Concept {arena: $arena, name: $a})
189
+ MATCH (b:Entity:Concept {arena: $arena, name: $b})
190
+ MERGE (a)-[r:CO_OCCURS]->(b)
191
+ ON CREATE SET r.weight = 0.5, r.created_at = $now
192
+ ON MATCH SET r.weight = coalesce(r.weight, 0.5) + 0.05
193
+ """,
194
+ arena="acme", a="Pricing", b="Negotiation", now="t",
195
+ )
196
+ cypher, _ = rec.calls[-1]
197
+ # Both endpoints carry arena in the property bag.
198
+ assert cypher.count("arena: $arena") == 2
199
+
200
+
201
+ def test_known_as_edge_links_email_and_name_within_arena() -> None:
202
+ """KNOWN_AS edge connects the (arena, name) and (arena, email) Person nodes."""
203
+ rec = _Recorder()
204
+ rec.run(
205
+ """
206
+ MATCH (n:Person {arena: $arena, name: $name})
207
+ MATCH (e:Person {arena: $arena, email: $email})
208
+ MERGE (n)-[:KNOWN_AS]->(e)
209
+ """,
210
+ arena="acme", name="Alex Tong", email="alex@acme.com",
211
+ )
212
+ cypher, _ = rec.calls[-1]
213
+ assert _has_arena_in_pattern(cypher, "Person")
214
+ assert "KNOWN_AS" in cypher
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # Self-test: the helper above flags the bug the lint missed in v1.
219
+ # ---------------------------------------------------------------------------
220
+
221
+
222
+ def test_helper_flags_unscoped_person_pattern() -> None:
223
+ bad_cypher = """
224
+ MERGE (p:Entity:Person {email: $email})
225
+ ON CREATE SET p.created_at = $now
226
+ MATCH (c:Chunk {arena: $arena, id: $cid})
227
+ MERGE (p)-[:MENTIONS]->(c)
228
+ """
229
+ assert not _has_arena_in_pattern(bad_cypher, "Person"), \
230
+ "helper must flag the unscoped Person pattern"
231
+ assert _has_arena_in_pattern(bad_cypher, "Chunk"), \
232
+ "Chunk pattern with arena in property bag should pass"
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Neo4j-backed integration tests — only run when env is set.
237
+ # ---------------------------------------------------------------------------
238
+
239
+
240
+ _NEO4J_URI = os.environ.get("NEO4J_TEST_URI")
241
+ _NEO4J_USER = os.environ.get("NEO4J_TEST_USER", "neo4j")
242
+ _NEO4J_PASSWORD = os.environ.get("NEO4J_TEST_PASSWORD")
243
+
244
+ _skip_no_neo4j = pytest.mark.skipif(
245
+ not (_NEO4J_URI and _NEO4J_PASSWORD),
246
+ reason="set NEO4J_TEST_URI + NEO4J_TEST_PASSWORD to run integration tests",
247
+ )
248
+
249
+
250
+ @pytest.fixture
251
+ def neo4j_driver():
252
+ """Open a Neo4j driver and clean test data on teardown.
253
+
254
+ Uses a randomised arena pair so concurrent test runs don't trample
255
+ each other; tears down by deleting nodes scoped to those arenas
256
+ (never a global wipe — this fixture must be safe against a
257
+ populated dev database).
258
+ """
259
+ from neo4j import GraphDatabase # local import keeps unit-only runs neo4j-free
260
+
261
+ driver = GraphDatabase.driver(_NEO4J_URI, auth=(_NEO4J_USER, _NEO4J_PASSWORD))
262
+ arenas = [f"test_a_{uuid.uuid4().hex[:8]}", f"test_b_{uuid.uuid4().hex[:8]}"]
263
+ yield driver, arenas
264
+ with driver.session() as session:
265
+ for arena in arenas:
266
+ session.run(
267
+ "MATCH (n) WHERE n.arena = $arena DETACH DELETE n",
268
+ arena=arena,
269
+ )
270
+ driver.close()
271
+
272
+
273
+ @_skip_no_neo4j
274
+ def test_two_arenas_get_distinct_person_nodes_for_same_email(neo4j_driver) -> None:
275
+ """Same contact_email in two arenas → two :Person nodes, not one."""
276
+ driver, (arena_a, arena_b) = neo4j_driver
277
+ email = "shared@example.com"
278
+ chunk_id = lambda a: f"c_{a}"
279
+
280
+ with driver.session() as session:
281
+ for arena in (arena_a, arena_b):
282
+ # Materialise the chunk that the Person attaches to.
283
+ session.run(
284
+ """
285
+ MERGE (c:Chunk {id: $cid})
286
+ SET c.arena = $arena, c.text = 'test', c.path = 'test',
287
+ c.created_at = '2026-05-08T00:00:00Z'
288
+ """,
289
+ cid=chunk_id(arena), arena=arena,
290
+ )
291
+ # Apply the same writer block as the engine would.
292
+ session.run(
293
+ """
294
+ MERGE (p:Entity:Person {arena: $arena, email: $email})
295
+ ON CREATE SET p.created_at = $now,
296
+ p.first_seen = $occurred_at,
297
+ p.last_seen = $occurred_at
298
+ WITH p
299
+ MATCH (c:Chunk {arena: $arena, id: $cid})
300
+ MERGE (p)-[r:COMMUNICATED]->(c)
301
+ ON CREATE SET r.channel = $channel,
302
+ r.direction = $direction,
303
+ r.occurred_at = $occurred_at,
304
+ r.weight = 1.0
305
+ """,
306
+ arena=arena, email=email, cid=chunk_id(arena),
307
+ channel="email", direction="inbound",
308
+ occurred_at="2026-05-08T00:00:00Z", now="2026-05-08T00:00:00Z",
309
+ )
310
+
311
+ # Assert two distinct nodes, one per arena, both with the same email.
312
+ result = session.run(
313
+ "MATCH (p:Person) WHERE p.email = $email RETURN p.arena AS arena",
314
+ email=email,
315
+ )
316
+ seen = sorted(rec["arena"] for rec in result if rec["arena"] in (arena_a, arena_b))
317
+ assert seen == sorted([arena_a, arena_b]), (
318
+ f"expected exactly one Person per arena for the same email, got {seen!r}"
319
+ )
320
+
321
+
322
+ @_skip_no_neo4j
323
+ def test_arena_scoped_search_does_not_traverse_other_arena(neo4j_driver) -> None:
324
+ """A graph search scoped to arena_a can't return arena_b nodes."""
325
+ driver, (arena_a, arena_b) = neo4j_driver
326
+ name = "Shared Concept"
327
+
328
+ with driver.session() as session:
329
+ for arena in (arena_a, arena_b):
330
+ session.run(
331
+ """
332
+ MERGE (c:Chunk {id: $cid})
333
+ SET c.arena = $arena, c.text = 't', c.path = 'p',
334
+ c.created_at = '2026-05-08T00:00:00Z'
335
+ MERGE (e:Entity:Concept {arena: $arena, name: $name})
336
+ ON CREATE SET e.weight = 1.0, e.created_at = '2026-05-08T00:00:00Z'
337
+ MERGE (e)-[:MENTIONS]->(c)
338
+ """,
339
+ cid=f"c_{arena}", arena=arena, name=name,
340
+ )
341
+
342
+ # Arena-scoped lookup as the engine search now does it.
343
+ result = session.run(
344
+ """
345
+ MATCH (n:Entity {name: $name, arena: $arena})
346
+ RETURN n.arena AS arena, n.name AS name
347
+ """,
348
+ name=name, arena=arena_a,
349
+ )
350
+ rows = list(result)
351
+ assert len(rows) == 1
352
+ assert rows[0]["arena"] == arena_a, (
353
+ f"arena-scoped match returned wrong arena: {rows[0]['arena']!r}"
354
+ )
355
+
356
+ # And the unscoped query (intentionally cross-tenant) still
357
+ # finds two — proves the data is there, isolation is real.
358
+ result = session.run(
359
+ "MATCH (n:Entity {name: $name}) RETURN n.arena AS arena", name=name,
360
+ )
361
+ all_rows = sorted(
362
+ r["arena"] for r in result if r["arena"] in (arena_a, arena_b)
363
+ )
364
+ assert all_rows == sorted([arena_a, arena_b])
365
+
366
+
367
+ @_skip_no_neo4j
368
+ def test_forget_arena_b_leaves_arena_a_intact(neo4j_driver) -> None:
369
+ """Tenant-scoped delete on arena_b doesn't affect arena_a."""
370
+ driver, (arena_a, arena_b) = neo4j_driver
371
+
372
+ with driver.session() as session:
373
+ for arena in (arena_a, arena_b):
374
+ session.run(
375
+ """
376
+ MERGE (c:Chunk {id: $cid})
377
+ SET c.arena = $arena, c.text = 't', c.path = 'p',
378
+ c.created_at = '2026-05-08T00:00:00Z'
379
+ MERGE (e:Entity:Concept {arena: $arena, name: 'thing'})
380
+ ON CREATE SET e.weight = 1.0, e.created_at = '2026-05-08T00:00:00Z'
381
+ MERGE (e)-[:MENTIONS]->(c)
382
+ """,
383
+ cid=f"c_{arena}", arena=arena,
384
+ )
385
+
386
+ # Tenant-scoped delete: same shape as forget-internal.
387
+ session.run(
388
+ "MATCH (c:Chunk {arena: $arena}) DETACH DELETE c", arena=arena_b,
389
+ )
390
+ session.run(
391
+ "MATCH (e:Entity {arena: $arena}) DETACH DELETE e", arena=arena_b,
392
+ )
393
+
394
+ # arena_a still intact.
395
+ a_chunks = session.run(
396
+ "MATCH (c:Chunk {arena: $arena}) RETURN count(c) AS n", arena=arena_a,
397
+ ).single()["n"]
398
+ a_entities = session.run(
399
+ "MATCH (e:Entity {arena: $arena}) RETURN count(e) AS n", arena=arena_a,
400
+ ).single()["n"]
401
+ assert a_chunks >= 1
402
+ assert a_entities >= 1
403
+
404
+ # arena_b gone.
405
+ b_chunks = session.run(
406
+ "MATCH (c:Chunk {arena: $arena}) RETURN count(c) AS n", arena=arena_b,
407
+ ).single()["n"]
408
+ b_entities = session.run(
409
+ "MATCH (e:Entity {arena: $arena}) RETURN count(e) AS n", arena=arena_b,
410
+ ).single()["n"]
411
+ assert b_chunks == 0
412
+ assert b_entities == 0