From 4cb21127ce985c4d3eefe7383a16384adc9087c5 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Wed, 19 Aug 2026 14:52:06 +0200 Subject: [PATCH 01/39] docs(spec): design for Strands MemoryStore integration Neo4jMemoryStore implementing Strands' long-term-memory MemoryStore protocol in both SDKs. Both Strands reviewers asked for it unprompted in harness-sdk#3871, where they also asked us to keep the session manager and the memory store apart as constructs. Verified against strands-agents 1.52.0 and @strands-agents/sdk 1.13.0: MemoryManager now ships its own per-model-call injection, and first-party vended stores exist to copy the idiom from. Notable outcomes: - One store class per SDK defining both write sinks, so extraction is server-side by default while add stays live for the manager's tools. - search() is an LTM fan-out over entities/preferences/facts, auto-gated per backend; add() writes a message into a deterministic sink conversation with extraction, with metadata["kind"] routing typed writes. - The store stays free of Strands internals: both coexistence guards live in the session manager, which is the side that gets an agent. - Store is positioned as the preferred memory construct; the session manager keeps transcript duties and gains no memory framing. --- .../2026-08-19-strands-memory-store-design.md | 374 ++++++++++++++++++ 1 file changed, 374 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-19-strands-memory-store-design.md diff --git a/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md b/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md new file mode 100644 index 00000000..170ae734 --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md @@ -0,0 +1,374 @@ +# Strands MemoryStore for Neo4j Agent Memory — Design + +**Date**: 2026-08-19 · **Status**: approved, not yet implemented +**Verified against**: `strands-agents==1.52.0` (PyPI), `@strands-agents/sdk@1.13.0` (npm) + +## Overview + +`Neo4jMemoryStore` implements Strands' long-term-memory `MemoryStore` protocol in +both SDKs, so an agent constructed with +`MemoryManager(stores=[Neo4jMemoryStore(...)])` recalls from a Neo4j graph across +sessions. Both Strands reviewers asked for this unprompted +([harness-sdk#3871](https://github.com/strands-agents/harness-sdk/pull/3871)); it is the +blocking piece of the Strands relationship. + +The store is an **adapter**, not new memory machinery. Every protocol member already +has a backing primitive in the library. + +## Goals + +- `MemoryStore` conformance in Python and TypeScript, with the same shape in both. +- Recall (`search`) over long-term memory: entities, preferences, facts. +- Server-side extraction via `add_messages` — no extra model call. +- Graph-native tools the manager cannot provide (`get_entity_graph`). +- Correct, documented behaviour when paired with `Neo4jSessionManager`. +- Establish the store as the preferred memory construct (see Positioning mandate). + +## Non-Goals (v1) + +- No `MemoryManager` subclass. It is framework-owned and concrete; we implement the + store only. +- No buffered/fire-and-forget writes (see Write durability). +- No new NAMS capabilities. The store lives within the entity-only surface NAMS exposes. +- No `searchFacts` in the TypeScript client. No endpoint exists behind it on REST. +- No changes to `Neo4jSessionManager`'s persistence behaviour, beyond two guards. +- No TCK work. The TCK certifies client conformance by tier and does not cover + integrations, so it cannot verify this integration's cross-language parity. + +## Verified contract + +`MemoryStore` is a `Protocol` (Python) / `interface` (TypeScript). Required: `name`, +`description`, `max_search_results`, `writable`, `extraction`, and `search()`. +Optional: `add()`, `add_messages()`, `initialize()`, `get_tools()`. + +Manager behaviour that shapes this design: + +| Fact | Source | +|---|---| +| Extraction mode is inferred from the sinks a store defines: `add` only → client-side `ModelExtractor`; `add_messages` present → server-side, no model call | `memory/extraction/resolve_extraction_config.py` | +| `_has_method` inspects `type(store)`, so an inherited Protocol stub counts as not-implemented | `memory/types.py` | +| A writable store must expose at least one sink; an extractor requires `add`; extraction without an extractor requires `add_messages` | `MemoryManager.__init__` validations | +| `search()` fans out with `return_exceptions=True`; per-store failures are logged, not fatal | `MemoryManager.search` | +| `add()` aggregates per-store failures into `AggregateMemoryError` | `MemoryManager.add` | +| `init_agent` calls `store.initialize()`, wires extraction triggers, registers injection middleware | `MemoryManager.init_agent` | +| Defaults: 3 search results per store, 5 injected entries, `IntervalTrigger(turns=5)` | `memory_manager.py` | +| `MemoryManager` ships **its own per-model-call injection**, default on, default trigger `"userTurn"`, folded into the last user message | `memory/types.py`, `injection/` | +| `Agent(session_manager=..., memory_manager=...)` are two first-class parameters; `agent.memory_manager` is public | `agent/agent.py:203-204` | +| `AddMessagesContext` carries only `sequence_numbers`, which reset every run — the store is handed no session identity | `memory/types.py` | + +## Positioning mandate + +Binding on the implementation and the docs. + +| Job | Preferred construct | Supersedes | +|---|---|---| +| Recall into the agent loop | **`Neo4jMemoryStore` in a `MemoryManager`** | `search_context` from `context_graph_tools`; `Neo4jRetrievalConfig` injection | +| Transcript persistence + restore | `Neo4jSessionManager` | nothing — not superseded, carries no memory framing | +| Deep graph queries the store does not cover | `context_graph_tools` | nothing — factory retained | +| strands < 1.44 | `retrieval_config` + tools | documented as the pre-`MemoryStore` path | + +In Strands' own vocabulary: the session manager restores sessions, the memory store +feeds the agent loop. + +## Architecture — Python + +New module `src/neo4j_agent_memory/integrations/strands/memory_store.py`, exported +from the package `__init__` through the existing try/except ImportError guard. +`_retrieval.py` gains a `MemoryEntry`-shaped sibling to `_retrieve_context`; +`_messages.py` is reused unchanged for message mapping. + +Shape follows the vended stores (`strands.vended_memory_stores.bedrock_knowledge_base`, +`test_memory_store`): subclass the Protocol, config as a `TypedDict`. + +```python +class Neo4jMemoryStore(MemoryStore): + def __init__(self, **store_config: Unpack[Neo4jMemoryStoreConfig]) -> None: ... + + @classmethod + def for_nams(cls, **store_config: Unpack[Neo4jMemoryStoreConfig]) -> Neo4jMemoryStore: ... +``` + +`for_nams` mirrors `Neo4jSessionManager.for_nams` and reads `MEMORY_API_KEY` / +`MEMORY_ENDPOINT` from the environment. + +### Config + +`Neo4jMemoryStoreConfig` extends `MemoryStoreConfig` with: + +| Field | Default | Purpose | +|---|---|---| +| `client` \| `settings` | — | pre-connected `MemoryClient`, or settings the store builds one from | +| `conversation_id` | minted in `initialize()` | write sink target | +| `user_id` | `None` | scopes reads in multi-tenant mode | +| `include_entities` | `True` | search fan-out | +| `include_preferences` | `True` | auto-gated off on NAMS | +| `include_facts` | `True` | auto-gated off on NAMS | +| `min_score` | `0.2` | bolt only; NAMS ignores `threshold` | +| `graph_tools` | `True` | expose `get_tools()` | + +Inherited attribute defaults: `writable=True`, `extraction=False`, +`max_search_results=None` (defers to the manager's 3), `description` defaults to a +string naming the graph. + +### Protocol mapping + +| Member | Maps to | Notes | +|---|---|---| +| `search` | concurrent `search_entities` / `search_preferences` / `search_facts` | reshape of `_retrieval.py` `_retrieve_context`. Per-kind failures isolated and logged; whole-search failures propagate (the manager isolates per store). Limit precedence: `options["max_search_results"]` → `self.max_search_results` → manager default | +| `add` | default: message into the sink conversation with extraction. `metadata["kind"]` ∈ `{preference, fact, entity}` routes to `add_preference` / `add_fact` / `add_entity` | `NotSupportedError` falls back to the default sink and logs once. **Not** `long_term.add()`, which makes the whole string an entity *name* with type `OBJECT` (`memory/long_term.py:389-398`) | +| `add_messages` | `bulk_add_messages(sink_id, msgs, extract_entities=True)` | protocol alias forwarding kwargs to `add_messages_batch` (`memory/short_term.py:560-572`) | +| `initialize` | connect an owned client; mint the sink conversation when `conversation_id` was omitted and a sink is reachable | idempotent | +| `get_tools` | `get_entity_graph`, `get_user_preferences` | logic re-bound over the store's own client, not the tools factory's cached clients. Backend-gated, see below | + +Tools are gated by what the backend actually exposes: + +| Tool | bolt | NAMS / TypeScript | +|---|---|---| +| `get_entity_graph` | `get_related_entities`, configurable depth | `expand_graph` / `expandGraph` — **1 hop only**, keyed by node id, so the name is resolved through `search_entities` first (`get_entity_by_name` is unsupported on NAMS) | +| `get_user_preferences` | `get_preferences_for` | omitted — NAMS exposes no preferences endpoint (`nams/long_term.py:459`) | + +Differing depth semantics are documented in the tool description the model sees, so the +tool never promises traversal the backend cannot do. + +`MemoryEntry` mapping — `content` is the formatted line (reusing `_format_entity` / +`_format_preference` / `_format_fact`), `metadata` carries: + +| Key | Value | +|---|---| +| `kind` | `entity` \| `preference` \| `fact` | +| `id` | node id | +| `type` | `full_type` for entities, `category` for preferences | +| `score` | similarity, **bolt only** — `search_entities` sets `entity.metadata["similarity"]`; NAMS returns no score | + +### Both sinks on one class + +The class defines `add` **and** `add_messages`. Consequences, by design: + +- Default extraction is server-side, no model call. +- `add` stays available for the manager's `add_memory` tool and programmatic `MemoryManager.add`. +- An explicit `extraction={"extractor": ModelExtractor()}` still validates, because `add` exists. + +This removes the need for a per-transport class split: one class per SDK. + +### Scoping and the sink conversation + +`AddMessagesContext` gives the store no session identity, so the store owns its scope +— matching the Bedrock KB precedent ("for per-tenant isolation, construct one store +per scope"). + +- Writes go to a **dedicated sink conversation**, minted in `initialize()` unless + `conversation_id` is supplied. Its session id is **deterministic** — + `strands-memory-store/{user_id or "_"}/{name}` — so `initialize()` is idempotent across + process restarts and repeated runs reuse one sink instead of accumulating orphans. + Where the backend supports conversation metadata at creation, the sink is also tagged + (mirroring `_SESSION_KEY = "strands_session_id"` in the session manager); NAMS exposes + no endpoint to set metadata after creation, so the tag is best-effort and the + deterministic id is the contract. +- Reads are conversation-independent; only `user_id` narrows them. +- Pointing the sink at the chat conversation duplicates `Message` nodes inside the + readable history. Documented as unsupported usage, not guarded. + +### Idempotency + +`CREATE_MESSAGE` uses `CREATE` with an internally generated `$id` — no caller-supplied +id, no `MERGE` (`graph/queries.py:68-86`), so message writes are not idempotent, while +Strands' extraction writes are explicitly at-least-once. + +Mitigation: an in-process set of `(run_id, sequence_number)`, `run_id` minted per store +instance. Retries occur within one process, which is the case the set covers. No schema +change, no library change. NAMS offers nothing better. + +### Write durability + +Awaited writes. `write_mode="buffered"` applies **only** to explicit +`client.buffered.submit(cypher, params)` calls; the memory layers do not route through +it (`memory/buffered.py:99`, `__init__.py:877`), so using the buffer would mean +hand-writing Cypher and bypassing embeddings, extraction and message linking. It is +also bolt-only. Durability stays where Strands puts it: background extraction plus +`MemoryManager.flush()`, and `wait_for_writes` on the add tool. (Resolves A6.) + +### Client ownership + +Mirrors the session manager: a store built from `settings` owns its client and closes it +via `aclose()` / async context manager; a store handed a live `MemoryClient` never closes +it. The existing warning against sharing one client between the tools factory and the +session manager extends to the store. + +## Coexistence with Neo4jSessionManager + +The pairing is legitimate — Strands exposes both as separate `Agent` parameters, and +transcript and knowledge are different jobs. Two overlaps need handling. + +**Both guards live in `session_manager.py`, not the store.** `initialize()` receives no +agent, so the store cannot see what else is attached; the session manager already gets +the agent at `AgentInitializedEvent`, and `agent.memory_manager` is public. The store +therefore stays free of coupling to Strands internals. Enumerating the manager's stores +reads `MemoryManager._stores`, a private — one read, in one file, pinned by a test that +fails loudly on a strands upgrade. + +### Guard 1 — double extraction (raises) + +The store can only extract by **re-writing** turns the session manager already +persisted; `extract_entities_from_session` (extract in place) is bolt-only +(`memory/short_term.py:1111`), so it cannot be the portable path. + +Compounding this: NAMS `add_message` accepts only `{content, role}` and silently drops +extraction kwargs (`nams/short_term.py:287-289`) — NAMS extracts server-side whatever is +written, so on NAMS `Neo4jSessionManager(extract_entities=...)` is a no-op and a second +sink conversation is extracted a second time. + +| Setup | Behaviour | +|---|---| +| Store alone (no `Neo4jSessionManager`) | store owns extraction — the textbook Strands split | +| Store + session manager | store defaults to recall-only; session manager persists, bolt extracts in place, NAMS extracts server-side | +| Store + session manager, `store.extraction` truthy | **raises at construction**, naming both one-line fixes | + +Recommended paired configuration, and the one the docs lead with: + +```python +Neo4jSessionManager(..., extract_entities=True) # transcript + extraction +Neo4jMemoryStore(name="graph") # recall only (extraction=False default) +``` + +### Guard 2 — double injection (warns) + +`MemoryManager` injection is default-on and lands in the same place as +`Neo4jRetrievalConfig` (both fold into the last user message on a user turn), so a +current `retrieval_config` user adopting the store gets memory injected twice — two +search fan-outs, two blocks. + +`Neo4jRetrievalConfig` is **kept and fully supported**: it is the only injection path +below strands 1.44, and its sources are declaratively configured rather than decided by +a store. When the session manager detects an active `MemoryManager` injecting from our +store, it logs a warning naming the duplication and both ways out — once per session-manager +instance, not per turn. No +deprecation, no runtime disabling. + +## Backend capability matrix + +| Capability | bolt | NAMS | +|---|---|---| +| `search` entities | yes, with score | yes, no score, `threshold` ignored, singular `type` | +| `search` preferences / facts | yes | `NotSupportedError` → auto-gated off | +| `add` kind routing | entity / preference / fact | entity only; others fall back to the sink | +| `add_messages` | yes, `extract_entities=True` honoured | yes, extraction server-side and unconditional | +| `get_entity_graph` tool | yes, configurable depth | yes via `expand_graph`, 1 hop | +| `get_user_preferences` tool | yes | omitted — no preferences endpoint | + +Source: `nams/long_term.py:1-27` and its `NotSupportedError` methods. + +## TypeScript parity + +Same shape, TS idiom: one options object, `class Neo4jMemoryStore implements MemoryStore`, +type-only SDK imports so the published bundle keeps zero runtime dependencies. +`getTools()` reaches `tool()` through the existing lazy `await import("@strands-agents/sdk")`. + +| Divergence | Handling | +|---|---| +| No `searchFacts`; `search_preferences`, `get_related_entities`, `add_preference`, `add_fact`, `add_relationship`, `get_entity_by_name` are `"unsupported"` on REST (`transport/rest.ts:171-177`) | **TS store is entities-only** for `search()`. `includeFacts` / `includePreferences` are absent from the TS options type — a compile error, not a silently ignored flag. `get_entity_graph` **is** available via `expandGraph` (1 hop, `transport/rest.ts:284`); `get_user_preferences` is not | +| `bulkAddMessages` caps at 100 per call (`short-term/index.ts:283`) | store chunks internally | +| No extraction flags on TS writes | none needed — NAMS extracts server-side | +| No bolt transport | `minScore` accepted, meaningful on Python/bolt only | + +Those methods exist in the TS client because `BridgeTransport` is a generic +`POST {endpoint}/{snake_case_method}` forwarder used by the TCK against a Python +reference adapter. They work on the bridge, throw on REST. TS and Python agree on the +NAMS surface, so no live-API verification is needed. + +Guards in TS attach in `Neo4jConversationManager.initAgent`, the only place an agent +reference arrives. Honest asymmetry: `Neo4jSessionStorage` used **without** the +conversation manager gets no guard, because nothing hands it an agent. + +## Error handling + +| Failure | Behaviour | +|---|---| +| One search kind fails | logged, skipped; other kinds still return | +| Whole `search` fails | propagates; the manager logs and continues with other stores | +| `add` / `add_messages` fails | propagates; the manager aggregates into `AggregateMemoryError` | +| `NotSupportedError` on a routed `add` | falls back to the sink, logs once per store | +| `initialize` fails | propagates — aborts agent construction, as Strands intends | +| Paired with store extraction on | `ValueError` at construction | + +## Testing + +| Layer | Coverage | +|---|---| +| Python unit | `search` fan-out with per-kind isolation; limit precedence; `MemoryEntry` metadata; `add` kind routing incl. `NotSupportedError` fallback; `add_messages` dedupe across a retried batch; sink minting idempotence; `get_tools`; client ownership; both guards | +| Python integration | bolt via docker-compose; NAMS gated on a key, skipped without | +| TS unit | mirrored test names against the existing msw/bridge setup, in `test/unit/strands/memory-store.test.ts` | +| Guards | extend `tests/unit/integrations/strands_fakes.py` with a fake agent exposing `memory_manager`; assert the private-attribute read fails loudly if strands moves it | +| Examples | `tests/examples/test_no_phantom_methods.py` covers the new example automatically | + +Cross-language parity is enforced by deliberately mirrored test names plus the tables in +this spec — not by the TCK, which does not cover integrations. + +## Documentation requirements + +1. Both guides lead their Quick Start with the store. `aws-strands.adoc` currently leads + with tools + session manager and gets restructured. +2. Memory-tools section: explicit note that `search_context` is superseded by the store + for recall; the factory is retained for deep graph work. +3. `Neo4jRetrievalConfig` section: same note for injection, pointing at `MemoryManager` + injection, consistent with Guard 2. +4. One sentence stating the split in Strands' vocabulary (session manager restores + sessions, memory store feeds the agent loop). +5. The word "memory" comes off the session-manager surface wherever it is not + load-bearing. +6. The three deployment shapes and the pairing rule appear in both guides. +7. TS guide states plainly that the TS store is entities-only, and why. + +Both guides were cross-linked in PR #181; the `MemoryStore` sections land in that structure. + +## Packaging + +- `pyproject.toml`: `strands` extra floor `strands-agents>=0.1.0` → `>=1.44.0` + (`MemoryStore` landed in 1.44.0; current release 1.52.0). Forces an upgrade on existing + session-manager users — accepted. +- `typescript/package.json`: devDep `@strands-agents/sdk` `^1.2.0` → `^1.13.0` + (TS memory landed in 1.6.0). +- CHANGELOG entries in both SDKs. +- Examples: `examples/strands-memory-store/` (Python, `llm=None` + local + sentence-transformers, no API keys, wired into `example-tests` CI) and a memory-store + variant under `typescript/examples/strands`. + +## Sequencing + +| PR | Contents | +|---|---| +| 1 | TS module split: `src/integrations/strands.ts` (846 lines) → `src/integrations/strands/` with `index.ts` re-exporting; `exports` map retargeted; no behaviour change. Sequenced first so the store lands on the final layout | +| 2 | Python store: implementation, tests, example, guide section, floor bump | +| 3 | TS store: implementation, tests, example, guide section, devDep bump | + +Afterwards, a second entry in the Strands integrations catalog +(`integrationType: memory-store`), bundled with the pending `maintainedBy: partner` and +session-manager description retune — one YAML PR against `strands-agents/harness-sdk`, +needing explicit approval. Context for the positioning ask: +[harness-sdk#3871](https://github.com/strands-agents/harness-sdk/pull/3871). + +## Key design decisions (record) + +| Decision | Outcome | +|---|---| +| one store class or two | **One class per SDK**, defining both sinks. `_has_method` fixes sinks per class in Python, but defining both gives server-side extraction by default and keeps `add` live | +| what `search()` maps to | LTM fan-out over entities / preferences / facts, configurable per kind, auto-gated by backend. Not `get_context()` — that blends short-term and reasoning | +| scoping | `user_id` scopes reads; `conversation_id` scopes writes; one store per scope | +| what `add()` writes | message into the sink with extraction by default; `metadata["kind"]` routes to a typed write | +| `get_tools()` | graph-only tools (`get_entity_graph` on both backends, `get_user_preferences` on bolt), on by default, `graph_tools=False` to suppress. Avoids the existing `add_memory` name collision between `context_graph_tools` and the manager's add tool | +| write durability | awaited; the buffered path is unusable without bypassing the message pipeline, and is bolt-only | +| TS parity | `Neo4jMemoryStore` from `@neo4j-labs/agent-memory/integrations/strands`, type-only SDK import, entities-only | +| which conversation `add_messages` uses | dedicated sink conversation, minted unless supplied | +| positioning | store is the preferred memory construct; session manager keeps transcript duties and gains no memory framing; two guards enforce the boundary at runtime | + +## Limitations (documented, accepted) + +- Retried extraction batches dedupe **in-process only**; a process restart mid-retry can + duplicate messages in the sink conversation. +- Relevance scores are bolt-only. NAMS entity search returns none. +- TS store is entities-only, so `search()` results are narrower than Python-on-bolt, and + its `get_entity_graph` traverses one hop rather than a configurable depth. +- A store paired with the session manager writes turns to a sink conversation separate + from the readable chat history, so turn text exists twice in the graph. Entities + converge via resolution/dedupe; the duplication is in messages, not knowledge. +- Guard 1 reads one private strands attribute (`MemoryManager._stores`). +- No guard in TS when `Neo4jSessionStorage` is used without `Neo4jConversationManager`. From 8260d45826a736e329e01420a6e43010414b2ef6 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Wed, 19 Aug 2026 17:06:52 +0200 Subject: [PATCH 02/39] docs(spec): condense to facts 374 -> 337 lines. Cut narrative framing and restated context; kept every file:line citation, version, default and decision (verified: 21 citations and version constraints before, 21 after). Dropped the "Key design decisions (record)" section, which restated the Architecture, Scoping, Write durability and TS parity sections. Its four unique claims moved to where they belong: get_context() exclusion to Non-Goals, the add_memory name collision to the get_tools row, one-store- per-scope to Scoping, type-only import to TS parity. Also removed a leftover "(Resolves A6.)" reference to the internal handover document. --- .../2026-08-19-strands-memory-store-design.md | 327 ++++++++---------- 1 file changed, 145 insertions(+), 182 deletions(-) diff --git a/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md b/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md index 170ae734..06b4b4e4 100644 --- a/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md +++ b/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md @@ -5,77 +5,72 @@ ## Overview -`Neo4jMemoryStore` implements Strands' long-term-memory `MemoryStore` protocol in -both SDKs, so an agent constructed with -`MemoryManager(stores=[Neo4jMemoryStore(...)])` recalls from a Neo4j graph across -sessions. Both Strands reviewers asked for this unprompted -([harness-sdk#3871](https://github.com/strands-agents/harness-sdk/pull/3871)); it is the -blocking piece of the Strands relationship. +`Neo4jMemoryStore` implements Strands' long-term-memory `MemoryStore` protocol in both +SDKs, so `MemoryManager(stores=[Neo4jMemoryStore(...)])` recalls from a Neo4j graph +across sessions. Requested by both Strands reviewers in +[harness-sdk#3871](https://github.com/strands-agents/harness-sdk/pull/3871). -The store is an **adapter**, not new memory machinery. Every protocol member already -has a backing primitive in the library. +It is an adapter: every protocol member has a backing primitive in the library already. ## Goals -- `MemoryStore` conformance in Python and TypeScript, with the same shape in both. -- Recall (`search`) over long-term memory: entities, preferences, facts. +- `MemoryStore` conformance in Python and TypeScript, same shape in both. +- `search()` over long-term memory: entities, preferences, facts. - Server-side extraction via `add_messages` — no extra model call. - Graph-native tools the manager cannot provide (`get_entity_graph`). -- Correct, documented behaviour when paired with `Neo4jSessionManager`. -- Establish the store as the preferred memory construct (see Positioning mandate). +- Defined behaviour when paired with `Neo4jSessionManager`. ## Non-Goals (v1) -- No `MemoryManager` subclass. It is framework-owned and concrete; we implement the - store only. -- No buffered/fire-and-forget writes (see Write durability). -- No new NAMS capabilities. The store lives within the entity-only surface NAMS exposes. -- No `searchFacts` in the TypeScript client. No endpoint exists behind it on REST. -- No changes to `Neo4jSessionManager`'s persistence behaviour, beyond two guards. -- No TCK work. The TCK certifies client conformance by tier and does not cover - integrations, so it cannot verify this integration's cross-language parity. +- No `MemoryManager` subclass — it is framework-owned and concrete. +- No buffered writes (see Write durability). +- No new NAMS capabilities; the store lives within NAMS's entity-only surface. +- No `searchFacts` in the TypeScript client — no REST endpoint behind it. +- No change to `Neo4jSessionManager` persistence behaviour beyond two guards. +- No TCK work: it certifies client conformance by tier and does not cover integrations. +- `search()` does not use `get_context()`, which blends short-term and reasoning memory. ## Verified contract `MemoryStore` is a `Protocol` (Python) / `interface` (TypeScript). Required: `name`, -`description`, `max_search_results`, `writable`, `extraction`, and `search()`. -Optional: `add()`, `add_messages()`, `initialize()`, `get_tools()`. +`description`, `max_search_results`, `writable`, `extraction`, `search()`. Optional: +`add()`, `add_messages()`, `initialize()`, `get_tools()`. Manager behaviour that shapes this design: | Fact | Source | |---|---| -| Extraction mode is inferred from the sinks a store defines: `add` only → client-side `ModelExtractor`; `add_messages` present → server-side, no model call | `memory/extraction/resolve_extraction_config.py` | +| Extraction mode follows the sinks a store defines: `add` only → client-side `ModelExtractor`; `add_messages` present → server-side, no model call | `memory/extraction/resolve_extraction_config.py` | | `_has_method` inspects `type(store)`, so an inherited Protocol stub counts as not-implemented | `memory/types.py` | -| A writable store must expose at least one sink; an extractor requires `add`; extraction without an extractor requires `add_messages` | `MemoryManager.__init__` validations | +| A writable store needs ≥1 sink; an extractor requires `add`; extraction without an extractor requires `add_messages` | `MemoryManager.__init__` | | `search()` fans out with `return_exceptions=True`; per-store failures are logged, not fatal | `MemoryManager.search` | | `add()` aggregates per-store failures into `AggregateMemoryError` | `MemoryManager.add` | | `init_agent` calls `store.initialize()`, wires extraction triggers, registers injection middleware | `MemoryManager.init_agent` | | Defaults: 3 search results per store, 5 injected entries, `IntervalTrigger(turns=5)` | `memory_manager.py` | -| `MemoryManager` ships **its own per-model-call injection**, default on, default trigger `"userTurn"`, folded into the last user message | `memory/types.py`, `injection/` | +| Manager ships its own per-model-call injection: default on, trigger `"userTurn"`, folded into the last user message | `memory/types.py`, `injection/` | | `Agent(session_manager=..., memory_manager=...)` are two first-class parameters; `agent.memory_manager` is public | `agent/agent.py:203-204` | -| `AddMessagesContext` carries only `sequence_numbers`, which reset every run — the store is handed no session identity | `memory/types.py` | +| `AddMessagesContext` carries only `sequence_numbers`, which reset every run — no session identity reaches the store | `memory/types.py` | ## Positioning mandate -Binding on the implementation and the docs. +Binding on implementation and docs. | Job | Preferred construct | Supersedes | |---|---|---| | Recall into the agent loop | **`Neo4jMemoryStore` in a `MemoryManager`** | `search_context` from `context_graph_tools`; `Neo4jRetrievalConfig` injection | -| Transcript persistence + restore | `Neo4jSessionManager` | nothing — not superseded, carries no memory framing | -| Deep graph queries the store does not cover | `context_graph_tools` | nothing — factory retained | +| Transcript persistence + restore | `Neo4jSessionManager` | nothing; carries no memory framing | +| Deep graph queries the store lacks | `context_graph_tools` | nothing; factory retained | | strands < 1.44 | `retrieval_config` + tools | documented as the pre-`MemoryStore` path | -In Strands' own vocabulary: the session manager restores sessions, the memory store -feeds the agent loop. +In Strands' vocabulary: the session manager restores sessions, the memory store feeds the +agent loop. ## Architecture — Python -New module `src/neo4j_agent_memory/integrations/strands/memory_store.py`, exported -from the package `__init__` through the existing try/except ImportError guard. -`_retrieval.py` gains a `MemoryEntry`-shaped sibling to `_retrieve_context`; -`_messages.py` is reused unchanged for message mapping. +New module `src/neo4j_agent_memory/integrations/strands/memory_store.py`, exported from +the package `__init__` through the existing try/except ImportError guard. `_retrieval.py` +gains a `MemoryEntry`-shaped sibling to `_retrieve_context`; `_messages.py` is reused +unchanged. Shape follows the vended stores (`strands.vended_memory_stores.bedrock_knowledge_base`, `test_memory_store`): subclass the Protocol, config as a `TypedDict`. @@ -88,7 +83,7 @@ class Neo4jMemoryStore(MemoryStore): def for_nams(cls, **store_config: Unpack[Neo4jMemoryStoreConfig]) -> Neo4jMemoryStore: ... ``` -`for_nams` mirrors `Neo4jSessionManager.for_nams` and reads `MEMORY_API_KEY` / +`for_nams` mirrors `Neo4jSessionManager.for_nams`, reading `MEMORY_API_KEY` / `MEMORY_ENDPOINT` from the environment. ### Config @@ -106,124 +101,111 @@ class Neo4jMemoryStore(MemoryStore): | `min_score` | `0.2` | bolt only; NAMS ignores `threshold` | | `graph_tools` | `True` | expose `get_tools()` | -Inherited attribute defaults: `writable=True`, `extraction=False`, -`max_search_results=None` (defers to the manager's 3), `description` defaults to a -string naming the graph. +Inherited defaults: `writable=True`, `extraction=False`, `max_search_results=None` +(defers to the manager's 3), `description` naming the graph. ### Protocol mapping | Member | Maps to | Notes | |---|---|---| -| `search` | concurrent `search_entities` / `search_preferences` / `search_facts` | reshape of `_retrieval.py` `_retrieve_context`. Per-kind failures isolated and logged; whole-search failures propagate (the manager isolates per store). Limit precedence: `options["max_search_results"]` → `self.max_search_results` → manager default | -| `add` | default: message into the sink conversation with extraction. `metadata["kind"]` ∈ `{preference, fact, entity}` routes to `add_preference` / `add_fact` / `add_entity` | `NotSupportedError` falls back to the default sink and logs once. **Not** `long_term.add()`, which makes the whole string an entity *name* with type `OBJECT` (`memory/long_term.py:389-398`) | +| `search` | concurrent `search_entities` / `search_preferences` / `search_facts` | reshape of `_retrieval.py` `_retrieve_context`. Per-kind failures isolated and logged; whole-search failures propagate. Limit precedence: `options["max_search_results"]` → `self.max_search_results` → manager default | +| `add` | default: message into the sink with extraction. `metadata["kind"]` ∈ `{preference, fact, entity}` routes to `add_preference` / `add_fact` / `add_entity` | `NotSupportedError` falls back to the sink, logged once. **Not** `long_term.add()`, which makes the whole string an entity *name* of type `OBJECT` (`memory/long_term.py:389-398`) | | `add_messages` | `bulk_add_messages(sink_id, msgs, extract_entities=True)` | protocol alias forwarding kwargs to `add_messages_batch` (`memory/short_term.py:560-572`) | -| `initialize` | connect an owned client; mint the sink conversation when `conversation_id` was omitted and a sink is reachable | idempotent | -| `get_tools` | `get_entity_graph`, `get_user_preferences` | logic re-bound over the store's own client, not the tools factory's cached clients. Backend-gated, see below | +| `initialize` | connect an owned client; mint the sink when `conversation_id` was omitted | idempotent | +| `get_tools` | `get_entity_graph`, `get_user_preferences` | bound to the store's own client, not the tools factory's cached clients. Excludes search/add, which would collide with `context_graph_tools`' `add_memory` and the manager's own tool of that name | -Tools are gated by what the backend actually exposes: +Tool availability is backend-gated; the differing depth is stated in the tool description +the model sees: | Tool | bolt | NAMS / TypeScript | |---|---|---| -| `get_entity_graph` | `get_related_entities`, configurable depth | `expand_graph` / `expandGraph` — **1 hop only**, keyed by node id, so the name is resolved through `search_entities` first (`get_entity_by_name` is unsupported on NAMS) | -| `get_user_preferences` | `get_preferences_for` | omitted — NAMS exposes no preferences endpoint (`nams/long_term.py:459`) | - -Differing depth semantics are documented in the tool description the model sees, so the -tool never promises traversal the backend cannot do. +| `get_entity_graph` | `get_related_entities`, configurable depth | `expand_graph` / `expandGraph` — 1 hop, keyed by node id, so the name resolves through `search_entities` first (`get_entity_by_name` unsupported on NAMS) | +| `get_user_preferences` | `get_preferences_for` | omitted — no preferences endpoint (`nams/long_term.py:459`) | -`MemoryEntry` mapping — `content` is the formatted line (reusing `_format_entity` / -`_format_preference` / `_format_fact`), `metadata` carries: +`MemoryEntry.content` is the formatted line (reusing `_format_entity` / +`_format_preference` / `_format_fact`); `metadata` carries: | Key | Value | |---|---| | `kind` | `entity` \| `preference` \| `fact` | | `id` | node id | | `type` | `full_type` for entities, `category` for preferences | -| `score` | similarity, **bolt only** — `search_entities` sets `entity.metadata["similarity"]`; NAMS returns no score | +| `score` | similarity, bolt only — `search_entities` sets `entity.metadata["similarity"]`; NAMS returns none | ### Both sinks on one class -The class defines `add` **and** `add_messages`. Consequences, by design: - -- Default extraction is server-side, no model call. -- `add` stays available for the manager's `add_memory` tool and programmatic `MemoryManager.add`. -- An explicit `extraction={"extractor": ModelExtractor()}` still validates, because `add` exists. - -This removes the need for a per-transport class split: one class per SDK. +The class defines `add` **and** `add_messages`, so: extraction defaults to server-side +with no model call; `add` stays available for the manager's `add_memory` tool and +programmatic `MemoryManager.add`; an explicit `extraction={"extractor": ModelExtractor()}` +still validates. One class per SDK, no per-transport split. ### Scoping and the sink conversation -`AddMessagesContext` gives the store no session identity, so the store owns its scope -— matching the Bedrock KB precedent ("for per-tenant isolation, construct one store -per scope"). - -- Writes go to a **dedicated sink conversation**, minted in `initialize()` unless - `conversation_id` is supplied. Its session id is **deterministic** — - `strands-memory-store/{user_id or "_"}/{name}` — so `initialize()` is idempotent across - process restarts and repeated runs reuse one sink instead of accumulating orphans. - Where the backend supports conversation metadata at creation, the sink is also tagged - (mirroring `_SESSION_KEY = "strands_session_id"` in the session manager); NAMS exposes - no endpoint to set metadata after creation, so the tag is best-effort and the - deterministic id is the contract. +`AddMessagesContext` carries no session identity, so the store owns its scope — matching +the Bedrock KB precedent ("for per-tenant isolation, construct one store per scope"). + +- Writes go to a dedicated sink conversation, minted in `initialize()` unless + `conversation_id` is given. Its session id is deterministic — + `strands-memory-store/{user_id or "_"}/{name}` — so restarts reuse one sink instead of + accumulating orphans. Where the backend accepts conversation metadata at creation the + sink is also tagged (mirroring `_SESSION_KEY` in the session manager); NAMS cannot set + metadata after creation, so the tag is best-effort and the deterministic id is the + contract. - Reads are conversation-independent; only `user_id` narrows them. -- Pointing the sink at the chat conversation duplicates `Message` nodes inside the - readable history. Documented as unsupported usage, not guarded. +- Pointing the sink at the chat conversation duplicates `Message` nodes in the readable + history: documented as unsupported, not guarded. ### Idempotency `CREATE_MESSAGE` uses `CREATE` with an internally generated `$id` — no caller-supplied -id, no `MERGE` (`graph/queries.py:68-86`), so message writes are not idempotent, while -Strands' extraction writes are explicitly at-least-once. - -Mitigation: an in-process set of `(run_id, sequence_number)`, `run_id` minted per store -instance. Retries occur within one process, which is the case the set covers. No schema -change, no library change. NAMS offers nothing better. +id, no `MERGE` (`graph/queries.py:68-86`) — so message writes are not idempotent, while +Strands' extraction writes are at-least-once. Mitigation: an in-process set of +`(run_id, sequence_number)`, `run_id` per store instance. Retries occur within one +process, which is what the set covers. NAMS offers nothing better. ### Write durability -Awaited writes. `write_mode="buffered"` applies **only** to explicit -`client.buffered.submit(cypher, params)` calls; the memory layers do not route through -it (`memory/buffered.py:99`, `__init__.py:877`), so using the buffer would mean -hand-writing Cypher and bypassing embeddings, extraction and message linking. It is -also bolt-only. Durability stays where Strands puts it: background extraction plus -`MemoryManager.flush()`, and `wait_for_writes` on the add tool. (Resolves A6.) +Awaited writes. `write_mode="buffered"` applies only to explicit +`client.buffered.submit(cypher, params)` calls; the memory layers do not route through it +(`memory/buffered.py:99`, `__init__.py:877`), so using the buffer would mean hand-writing +Cypher and bypassing embeddings, extraction and message linking. It is also bolt-only. +Durability stays where Strands puts it: background extraction plus +`MemoryManager.flush()`, and `wait_for_writes` on the add tool. ### Client ownership -Mirrors the session manager: a store built from `settings` owns its client and closes it -via `aclose()` / async context manager; a store handed a live `MemoryClient` never closes -it. The existing warning against sharing one client between the tools factory and the -session manager extends to the store. +As the session manager: a store built from `settings` owns its client and closes it via +`aclose()` / async context manager; a store handed a live `MemoryClient` never closes it. +The existing warning against sharing one client between the tools factory and the session +manager extends to the store. ## Coexistence with Neo4jSessionManager -The pairing is legitimate — Strands exposes both as separate `Agent` parameters, and -transcript and knowledge are different jobs. Two overlaps need handling. +The pairing is legitimate — two first-class `Agent` parameters, and transcript and +knowledge are different jobs. Two overlaps need handling. -**Both guards live in `session_manager.py`, not the store.** `initialize()` receives no -agent, so the store cannot see what else is attached; the session manager already gets -the agent at `AgentInitializedEvent`, and `agent.memory_manager` is public. The store -therefore stays free of coupling to Strands internals. Enumerating the manager's stores -reads `MemoryManager._stores`, a private — one read, in one file, pinned by a test that -fails loudly on a strands upgrade. +Both guards live in `session_manager.py`, not the store: `initialize()` receives no +agent, while the session manager gets one at `AgentInitializedEvent` and +`agent.memory_manager` is public. The store therefore stays free of Strands internals. +Enumerating the manager's stores reads `MemoryManager._stores`, a private — one read, in +one file, pinned by a test that fails loudly on a strands upgrade. ### Guard 1 — double extraction (raises) -The store can only extract by **re-writing** turns the session manager already -persisted; `extract_entities_from_session` (extract in place) is bolt-only -(`memory/short_term.py:1111`), so it cannot be the portable path. - -Compounding this: NAMS `add_message` accepts only `{content, role}` and silently drops -extraction kwargs (`nams/short_term.py:287-289`) — NAMS extracts server-side whatever is -written, so on NAMS `Neo4jSessionManager(extract_entities=...)` is a no-op and a second -sink conversation is extracted a second time. +The store can only extract by re-writing turns the session manager already persisted: +`extract_entities_from_session` (extract in place) is bolt-only +(`memory/short_term.py:1111`) and cannot be the portable path. Compounding it, NAMS +`add_message` accepts only `{content, role}` and silently drops extraction kwargs +(`nams/short_term.py:287-289`), so on NAMS `Neo4jSessionManager(extract_entities=...)` is +a no-op and a sink conversation is extracted a second time regardless. | Setup | Behaviour | |---|---| -| Store alone (no `Neo4jSessionManager`) | store owns extraction — the textbook Strands split | -| Store + session manager | store defaults to recall-only; session manager persists, bolt extracts in place, NAMS extracts server-side | -| Store + session manager, `store.extraction` truthy | **raises at construction**, naming both one-line fixes | +| Store alone | store owns extraction — the textbook Strands split | +| Store + session manager | store recall-only; session manager persists, bolt extracts in place, NAMS server-side | +| Store + session manager, `store.extraction` truthy | raises at construction, naming both one-line fixes | -Recommended paired configuration, and the one the docs lead with: +Recommended paired configuration, which the docs lead with: ```python Neo4jSessionManager(..., extract_entities=True) # transcript + extraction @@ -232,52 +214,51 @@ Neo4jMemoryStore(name="graph") # recall only (extraction=Fals ### Guard 2 — double injection (warns) -`MemoryManager` injection is default-on and lands in the same place as -`Neo4jRetrievalConfig` (both fold into the last user message on a user turn), so a -current `retrieval_config` user adopting the store gets memory injected twice — two -search fan-outs, two blocks. +Manager injection is default-on and lands where `Neo4jRetrievalConfig` does — both fold +into the last user message on a user turn — so a current `retrieval_config` user adopting +the store gets two search fan-outs and two blocks. -`Neo4jRetrievalConfig` is **kept and fully supported**: it is the only injection path -below strands 1.44, and its sources are declaratively configured rather than decided by -a store. When the session manager detects an active `MemoryManager` injecting from our -store, it logs a warning naming the duplication and both ways out — once per session-manager -instance, not per turn. No -deprecation, no runtime disabling. +`Neo4jRetrievalConfig` is kept and fully supported: it is the only injection path below +strands 1.44, and its sources are configured declaratively rather than decided by a +store. When the session manager detects a `MemoryManager` injecting from our store it +logs a warning naming the duplication and both ways out, once per session-manager +instance. No deprecation, no runtime disabling. ## Backend capability matrix +Source: `nams/long_term.py:1-27` and its `NotSupportedError` methods. + | Capability | bolt | NAMS | |---|---|---| -| `search` entities | yes, with score | yes, no score, `threshold` ignored, singular `type` | +| `search` entities | yes, with score | yes; no score, `threshold` ignored, singular `type` | | `search` preferences / facts | yes | `NotSupportedError` → auto-gated off | | `add` kind routing | entity / preference / fact | entity only; others fall back to the sink | -| `add_messages` | yes, `extract_entities=True` honoured | yes, extraction server-side and unconditional | +| `add_messages` | yes, `extract_entities=True` honoured | yes; extraction server-side and unconditional | | `get_entity_graph` tool | yes, configurable depth | yes via `expand_graph`, 1 hop | -| `get_user_preferences` tool | yes | omitted — no preferences endpoint | - -Source: `nams/long_term.py:1-27` and its `NotSupportedError` methods. +| `get_user_preferences` tool | yes | omitted | ## TypeScript parity -Same shape, TS idiom: one options object, `class Neo4jMemoryStore implements MemoryStore`, -type-only SDK imports so the published bundle keeps zero runtime dependencies. -`getTools()` reaches `tool()` through the existing lazy `await import("@strands-agents/sdk")`. +Same shape in TS idiom: one options object, `class Neo4jMemoryStore implements +MemoryStore`, type-only SDK imports so the published bundle keeps zero runtime +dependencies. `getTools()` reaches `tool()` through the existing lazy +`await import("@strands-agents/sdk")`. | Divergence | Handling | |---|---| -| No `searchFacts`; `search_preferences`, `get_related_entities`, `add_preference`, `add_fact`, `add_relationship`, `get_entity_by_name` are `"unsupported"` on REST (`transport/rest.ts:171-177`) | **TS store is entities-only** for `search()`. `includeFacts` / `includePreferences` are absent from the TS options type — a compile error, not a silently ignored flag. `get_entity_graph` **is** available via `expandGraph` (1 hop, `transport/rest.ts:284`); `get_user_preferences` is not | +| `searchFacts` absent; `search_preferences`, `get_related_entities`, `add_preference`, `add_fact`, `add_relationship`, `get_entity_by_name` are `"unsupported"` on REST (`transport/rest.ts:171-177`) | TS store is entities-only for `search()`. `includeFacts` / `includePreferences` are absent from the TS options type — a compile error, not a silently ignored flag. `get_entity_graph` is available via `expandGraph` (1 hop, `transport/rest.ts:284`); `get_user_preferences` is not | | `bulkAddMessages` caps at 100 per call (`short-term/index.ts:283`) | store chunks internally | | No extraction flags on TS writes | none needed — NAMS extracts server-side | | No bolt transport | `minScore` accepted, meaningful on Python/bolt only | Those methods exist in the TS client because `BridgeTransport` is a generic `POST {endpoint}/{snake_case_method}` forwarder used by the TCK against a Python -reference adapter. They work on the bridge, throw on REST. TS and Python agree on the +reference adapter: they work on the bridge, throw on REST. TS and Python agree on the NAMS surface, so no live-API verification is needed. -Guards in TS attach in `Neo4jConversationManager.initAgent`, the only place an agent -reference arrives. Honest asymmetry: `Neo4jSessionStorage` used **without** the -conversation manager gets no guard, because nothing hands it an agent. +TS guards attach in `Neo4jConversationManager.initAgent`, the only place an agent +reference arrives. `Neo4jSessionStorage` used without the conversation manager therefore +gets no guard. ## Error handling @@ -286,8 +267,8 @@ conversation manager gets no guard, because nothing hands it an agent. | One search kind fails | logged, skipped; other kinds still return | | Whole `search` fails | propagates; the manager logs and continues with other stores | | `add` / `add_messages` fails | propagates; the manager aggregates into `AggregateMemoryError` | -| `NotSupportedError` on a routed `add` | falls back to the sink, logs once per store | -| `initialize` fails | propagates — aborts agent construction, as Strands intends | +| `NotSupportedError` on a routed `add` | falls back to the sink, logged once per store | +| `initialize` fails | propagates, aborting agent construction as Strands intends | | Paired with store extraction on | `ValueError` at construction | ## Testing @@ -300,33 +281,31 @@ conversation manager gets no guard, because nothing hands it an agent. | Guards | extend `tests/unit/integrations/strands_fakes.py` with a fake agent exposing `memory_manager`; assert the private-attribute read fails loudly if strands moves it | | Examples | `tests/examples/test_no_phantom_methods.py` covers the new example automatically | -Cross-language parity is enforced by deliberately mirrored test names plus the tables in -this spec — not by the TCK, which does not cover integrations. +Cross-language parity rests on mirrored test names and this spec's tables, not the TCK. ## Documentation requirements -1. Both guides lead their Quick Start with the store. `aws-strands.adoc` currently leads +1. Both guides lead their Quick Start with the store; `aws-strands.adoc` currently leads with tools + session manager and gets restructured. -2. Memory-tools section: explicit note that `search_context` is superseded by the store - for recall; the factory is retained for deep graph work. -3. `Neo4jRetrievalConfig` section: same note for injection, pointing at `MemoryManager` - injection, consistent with Guard 2. -4. One sentence stating the split in Strands' vocabulary (session manager restores - sessions, memory store feeds the agent loop). -5. The word "memory" comes off the session-manager surface wherever it is not - load-bearing. -6. The three deployment shapes and the pairing rule appear in both guides. -7. TS guide states plainly that the TS store is entities-only, and why. - -Both guides were cross-linked in PR #181; the `MemoryStore` sections land in that structure. +2. Memory-tools section: `search_context` is superseded by the store for recall; the + factory is retained for deep graph work. +3. `Neo4jRetrievalConfig` section: superseded for injection by `MemoryManager` injection, + consistent with Guard 2. +4. Both guides state the split in Strands' vocabulary and carry the three deployment + shapes plus the pairing rule. +5. "Memory" comes off the session-manager surface wherever it is not load-bearing. +6. TS guide states the store is entities-only, and why. + +The guides were cross-linked in PR #181; the `MemoryStore` sections land in that +structure. ## Packaging -- `pyproject.toml`: `strands` extra floor `strands-agents>=0.1.0` → `>=1.44.0` - (`MemoryStore` landed in 1.44.0; current release 1.52.0). Forces an upgrade on existing +- `pyproject.toml`: `strands` extra `strands-agents>=0.1.0` → `>=1.44.0` (`MemoryStore` + landed in 1.44.0; current release 1.52.0). Forces an upgrade on existing session-manager users — accepted. -- `typescript/package.json`: devDep `@strands-agents/sdk` `^1.2.0` → `^1.13.0` - (TS memory landed in 1.6.0). +- `typescript/package.json`: devDep `@strands-agents/sdk` `^1.2.0` → `^1.13.0` (TS memory + landed in 1.6.0). - CHANGELOG entries in both SDKs. - Examples: `examples/strands-memory-store/` (Python, `llm=None` + local sentence-transformers, no API keys, wired into `example-tests` CI) and a memory-store @@ -336,39 +315,23 @@ Both guides were cross-linked in PR #181; the `MemoryStore` sections land in tha | PR | Contents | |---|---| -| 1 | TS module split: `src/integrations/strands.ts` (846 lines) → `src/integrations/strands/` with `index.ts` re-exporting; `exports` map retargeted; no behaviour change. Sequenced first so the store lands on the final layout | +| 1 | TS module split: `src/integrations/strands.ts` (846 lines) → `src/integrations/strands/` with `index.ts` re-exporting; no behaviour change. First, so the store lands on the final layout | | 2 | Python store: implementation, tests, example, guide section, floor bump | | 3 | TS store: implementation, tests, example, guide section, devDep bump | -Afterwards, a second entry in the Strands integrations catalog -(`integrationType: memory-store`), bundled with the pending `maintainedBy: partner` and -session-manager description retune — one YAML PR against `strands-agents/harness-sdk`, -needing explicit approval. Context for the positioning ask: -[harness-sdk#3871](https://github.com/strands-agents/harness-sdk/pull/3871). - -## Key design decisions (record) - -| Decision | Outcome | -|---|---| -| one store class or two | **One class per SDK**, defining both sinks. `_has_method` fixes sinks per class in Python, but defining both gives server-side extraction by default and keeps `add` live | -| what `search()` maps to | LTM fan-out over entities / preferences / facts, configurable per kind, auto-gated by backend. Not `get_context()` — that blends short-term and reasoning | -| scoping | `user_id` scopes reads; `conversation_id` scopes writes; one store per scope | -| what `add()` writes | message into the sink with extraction by default; `metadata["kind"]` routes to a typed write | -| `get_tools()` | graph-only tools (`get_entity_graph` on both backends, `get_user_preferences` on bolt), on by default, `graph_tools=False` to suppress. Avoids the existing `add_memory` name collision between `context_graph_tools` and the manager's add tool | -| write durability | awaited; the buffered path is unusable without bypassing the message pipeline, and is bolt-only | -| TS parity | `Neo4jMemoryStore` from `@neo4j-labs/agent-memory/integrations/strands`, type-only SDK import, entities-only | -| which conversation `add_messages` uses | dedicated sink conversation, minted unless supplied | -| positioning | store is the preferred memory construct; session manager keeps transcript duties and gains no memory framing; two guards enforce the boundary at runtime | +Then a second Strands catalog entry (`integrationType: memory-store`), bundled with the +pending `maintainedBy: partner` and session-manager description retune — one YAML PR +against `strands-agents/harness-sdk`, needing explicit approval. ## Limitations (documented, accepted) -- Retried extraction batches dedupe **in-process only**; a process restart mid-retry can - duplicate messages in the sink conversation. -- Relevance scores are bolt-only. NAMS entity search returns none. -- TS store is entities-only, so `search()` results are narrower than Python-on-bolt, and - its `get_entity_graph` traverses one hop rather than a configurable depth. -- A store paired with the session manager writes turns to a sink conversation separate - from the readable chat history, so turn text exists twice in the graph. Entities - converge via resolution/dedupe; the duplication is in messages, not knowledge. +- Retried extraction batches dedupe in-process only; a restart mid-retry can duplicate + messages in the sink conversation. +- Relevance scores are bolt-only. +- The TS store is entities-only, so `search()` is narrower than Python-on-bolt, and its + `get_entity_graph` traverses one hop rather than a configurable depth. +- A store paired with the session manager writes turns to a sink separate from the + readable history, so turn text exists twice in the graph. Entities converge via + resolution/dedupe; the duplication is in messages, not knowledge. - Guard 1 reads one private strands attribute (`MemoryManager._stores`). -- No guard in TS when `Neo4jSessionStorage` is used without `Neo4jConversationManager`. +- No TS guard when `Neo4jSessionStorage` is used without `Neo4jConversationManager`. From 71a5779fbdc1b0dbf6dcb20f746e0ecf2ed1dc82 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Wed, 19 Aug 2026 17:00:38 +0200 Subject: [PATCH 03/39] chore(strands): require strands-agents>=1.44.0 for the LTM MemoryStore MemoryStore landed in 1.44.0; the previous >=0.1.0 floor resolved to 1.23.0, which has no strands.memory module at all. --- pyproject.toml | 2 +- .../test_strands_memory_protocol.py | 37 +++++++++++++++++++ uv.lock | 10 +++-- 3 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 tests/unit/integrations/test_strands_memory_protocol.py diff --git a/pyproject.toml b/pyproject.toml index b2a4a9a7..352acfa0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ google = ["google-cloud-aiplatform>=1.38.0"] aws = ["boto3>=1.26.0"] # AWS Strands Agents SDK -strands = ["strands-agents>=0.1.0", "boto3>=1.26.0"] +strands = ["strands-agents>=1.44.0", "boto3>=1.26.0"] # Entity extraction spacy = ["spacy>=3.7.0"] diff --git a/tests/unit/integrations/test_strands_memory_protocol.py b/tests/unit/integrations/test_strands_memory_protocol.py new file mode 100644 index 00000000..a2be930b --- /dev/null +++ b/tests/unit/integrations/test_strands_memory_protocol.py @@ -0,0 +1,37 @@ +"""The store depends on strands' LTM module, added in strands-agents 1.44.0.""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("strands", reason="strands-agents not installed") + + +class TestMemoryProtocolAvailable: + def test_memory_module_exports_the_protocol(self) -> None: + from strands.memory import ( + AddMessagesContext, + MemoryEntry, + MemoryStore, + MemoryStoreConfig, + SearchOptions, + ) + + assert MemoryStore is not None + assert MemoryStoreConfig is not None + assert SearchOptions is not None + assert AddMessagesContext is not None + assert MemoryEntry(content="x").content == "x" + + def test_optional_methods_are_detected_by_type_not_instance(self) -> None: + """_has_method inspects type(store); an inherited stub counts as absent.""" + from strands.memory.types import MemoryStore as Proto + from strands.memory.types import _has_method + + class Bare(Proto): + async def search(self, query: str, options: object = None) -> list[object]: + return [] + + assert _has_method(Bare(), "search") is True + assert _has_method(Bare(), "add") is False + assert _has_method(Bare(), "add_messages") is False diff --git a/uv.lock b/uv.lock index fffe1a5e..5973e328 100644 --- a/uv.lock +++ b/uv.lock @@ -4780,7 +4780,7 @@ requires-dist = [ { name = "sentence-transformers", marker = "extra == 'sentence-transformers'", specifier = ">=2.2.0" }, { name = "spacy", marker = "extra == 'extraction'", specifier = ">=3.7.0" }, { name = "spacy", marker = "extra == 'spacy'", specifier = ">=3.7.0" }, - { name = "strands-agents", marker = "extra == 'strands'", specifier = ">=0.1.0" }, + { name = "strands-agents", marker = "extra == 'strands'", specifier = ">=1.44.0" }, { name = "typing-extensions", specifier = ">=4.4" }, ] provides-extras = ["openai", "anthropic", "sentence-transformers", "vertex-ai", "bedrock", "litellm", "instructor", "google", "aws", "strands", "spacy", "gliner", "extraction", "fuzzy", "cli", "opentelemetry", "opik", "observability", "langchain", "llamaindex", "pydantic-ai", "crewai", "openai-agents", "microsoft-agent", "mcp", "google-adk", "nams", "all", "full"] @@ -8214,24 +8214,26 @@ wheels = [ [[package]] name = "strands-agents" -version = "1.23.0" +version = "1.52.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "boto3" }, { name = "botocore" }, { name = "docstring-parser" }, + { name = "httpx" }, { name = "jsonschema" }, { name = "mcp" }, { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation-threading" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, + { name = "pyyaml" }, { name = "typing-extensions" }, { name = "watchdog" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/e6/27e7dad580b0a91430e67c667c1e0abd9beb363e4f2830d361ff46a6af7e/strands_agents-1.23.0.tar.gz", hash = "sha256:85b528218b3bdf2629dd64926ec0a7dd1ce781e5466693310a9f55b67a41c3f9", size = 709108, upload-time = "2026-01-21T20:15:06.413Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/f5/7bcd5018b80dcc9eb70bb315ad42d0e8e1607473689a246bdbb763433f5a/strands_agents-1.52.0.tar.gz", hash = "sha256:e79f08527cf3b03ace3ead081f71ee43ee33de7285a4faa9ecafaef4618afeb0", size = 1333918, upload-time = "2026-08-12T18:52:03.743Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/64/722a164f0bb4351da6918594496d831295136bba1be8a76caceb4fac4d6c/strands_agents-1.23.0-py3-none-any.whl", hash = "sha256:76cb65e7c667d06334940843a5c4fd56baab7b11f5d9997ab8c250dcc3c1b403", size = 335387, upload-time = "2026-01-21T20:15:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/32/28/6b1733dfb50ccf2b250d892ff9fdb0c987d0486febaf1305c5b6a904154b/strands_agents-1.52.0-py3-none-any.whl", hash = "sha256:768ff0e865df9f18154a2111f71e0c7683b8505e897704b89981cf7f11667bee", size = 687868, upload-time = "2026-08-12T18:52:01.538Z" }, ] [[package]] From 7e9626fbe2b4c0cda9a02311fe4ff6efcd724768 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Wed, 19 Aug 2026 17:14:31 +0200 Subject: [PATCH 04/39] feat(strands): add _retrieve_entries long-term fan-out Sibling to _retrieve_context that returns structured rows instead of a formatted context block, so a MemoryStore can map them to MemoryEntry. Per-kind error isolation; preference/fact searches skipped on NAMS. --- .../integrations/strands/_retrieval.py | 72 ++++++++++++++ tests/unit/integrations/strands_fakes.py | 6 ++ .../test_strands_retrieval_entries.py | 98 +++++++++++++++++++ 3 files changed, 176 insertions(+) create mode 100644 tests/unit/integrations/test_strands_retrieval_entries.py diff --git a/src/neo4j_agent_memory/integrations/strands/_retrieval.py b/src/neo4j_agent_memory/integrations/strands/_retrieval.py index c9aba28e..7f426b37 100644 --- a/src/neo4j_agent_memory/integrations/strands/_retrieval.py +++ b/src/neo4j_agent_memory/integrations/strands/_retrieval.py @@ -89,3 +89,75 @@ async def _retrieve_context( return "" body = "\n".join(f"- {line}" for line in lines) return f"<{cfg.context_tag}>\nRelevant memory:\n{body}\n" + + +@dataclass +class _EntryRow: + """One long-term hit, formatted for a Strands ``MemoryEntry``.""" + + content: str + metadata: dict[str, Any] + + +def _entity_row(entity: Entity) -> _EntryRow: + metadata: dict[str, Any] = { + "kind": "entity", + "id": str(entity.id), + "type": entity.full_type or entity.type, + } + score = (entity.metadata or {}).get("similarity") + if score is not None: + # NAMS never sets "similarity" — omit rather than default to 0, which + # would misrepresent an unscored hit as a bad match. + metadata["score"] = score + return _EntryRow(content=_format_entity(entity), metadata=metadata) + + +def _preference_row(preference: Preference) -> _EntryRow: + return _EntryRow( + content=_format_preference(preference), + metadata={"kind": "preference", "id": str(preference.id), "type": preference.category}, + ) + + +def _fact_row(fact: Fact) -> _EntryRow: + return _EntryRow( + content=_format_fact(fact), + metadata={"kind": "fact", "id": str(fact.id), "type": fact.predicate}, + ) + + +async def _retrieve_entries( + long_term: LongTermProtocol, + query: str, + *, + limit: int, + min_score: float, + include_entities: bool, + include_preferences: bool, + include_facts: bool, + nams: bool, +) -> list[_EntryRow]: + """Sibling of ``_retrieve_context``: same fan-out, rows instead of a string. + + Per-kind failures are logged and skipped so one dead index doesn't lose + the others' hits. NAMS has no preference/fact search endpoints, so those + are skipped rather than raised on every call. + """ + wanted: list[tuple[str, bool, Callable[..., Awaitable[list[Any]]], Callable[..., _EntryRow]]] = [ + ("entity", include_entities, long_term.search_entities, _entity_row), + ("preference", include_preferences and not nams, long_term.search_preferences, _preference_row), + ("fact", include_facts and not nams, long_term.search_facts, _fact_row), + ] + active = [(kind, search, row) for kind, on, search, row in wanted if on] + results = await asyncio.gather( + *(search(query, limit=limit, threshold=min_score) for _, search, _ in active), + return_exceptions=True, + ) + rows: list[_EntryRow] = [] + for (kind, _, to_row), result in zip(active, results): + if isinstance(result, BaseException): + logger.warning("Long-term %s search failed: %s", kind, result) + continue + rows.extend(to_row(item) for item in result) + return rows diff --git a/tests/unit/integrations/strands_fakes.py b/tests/unit/integrations/strands_fakes.py index e9515384..d2e1c4d8 100644 --- a/tests/unit/integrations/strands_fakes.py +++ b/tests/unit/integrations/strands_fakes.py @@ -96,6 +96,8 @@ def __init__(self) -> None: self.preferences: list[Any] = [] self.facts: list[Any] = [] self.fail_searches = False + self.fail_preferences = False + self.fail_facts = False self.search_calls: int = 0 async def _maybe_fail(self) -> None: @@ -109,11 +111,15 @@ async def search_entities(self, query: str, **kwargs: Any) -> list[Any]: async def search_preferences(self, query: str, **kwargs: Any) -> list[Any]: self.search_calls += 1 + if self.fail_preferences: + raise RuntimeError("preference backend down") await self._maybe_fail() return self.preferences async def search_facts(self, query: str, **kwargs: Any) -> list[Any]: self.search_calls += 1 + if self.fail_facts: + raise RuntimeError("fact backend down") await self._maybe_fail() return self.facts diff --git a/tests/unit/integrations/test_strands_retrieval_entries.py b/tests/unit/integrations/test_strands_retrieval_entries.py new file mode 100644 index 00000000..8fa044e2 --- /dev/null +++ b/tests/unit/integrations/test_strands_retrieval_entries.py @@ -0,0 +1,98 @@ +"""_retrieve_entries: concurrent long-term fan-out returning entry rows.""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("strands", reason="strands-agents not installed") + +from neo4j_agent_memory.memory.long_term import Entity, Fact, Preference + + +def _entity() -> Entity: + e = Entity(name="Acme Corp", type="ORGANIZATION") + e.metadata["similarity"] = 0.83 + return e + + +class TestRetrieveEntries: + @pytest.mark.asyncio + async def test_maps_each_kind_to_a_row_with_metadata(self) -> None: + from neo4j_agent_memory.integrations.strands._retrieval import _retrieve_entries + from tests.unit.integrations.strands_fakes import FakeLongTerm + + long_term = FakeLongTerm() + long_term.entities = [_entity()] + long_term.preferences = [Preference(category="ui", preference="dark mode")] + long_term.facts = [Fact(subject="Ada", predicate="works_at", object="Acme")] + + rows = await _retrieve_entries( + long_term, + "acme", + limit=10, + min_score=0.2, + include_entities=True, + include_preferences=True, + include_facts=True, + nams=False, + ) + + kinds = [r.metadata["kind"] for r in rows] + assert kinds == ["entity", "preference", "fact"] + assert rows[0].content == "[entity] Acme Corp (ORGANIZATION)" + assert rows[0].metadata["score"] == 0.83 + assert rows[0].metadata["type"] == "ORGANIZATION" + assert rows[1].metadata["type"] == "ui" + assert "id" in rows[0].metadata + + @pytest.mark.asyncio + async def test_nams_gates_preferences_and_facts_off(self) -> None: + from neo4j_agent_memory.integrations.strands._retrieval import _retrieve_entries + from tests.unit.integrations.strands_fakes import FakeLongTerm + + long_term = FakeLongTerm() + long_term.entities = [_entity()] + long_term.preferences = [Preference(category="ui", preference="dark mode")] + + rows = await _retrieve_entries( + long_term, "q", limit=10, min_score=0.2, + include_entities=True, include_preferences=True, include_facts=True, + nams=True, + ) + + assert [r.metadata["kind"] for r in rows] == ["entity"] + assert long_term.search_calls == 1 # preferences/facts never called + + @pytest.mark.asyncio + async def test_one_failing_kind_does_not_lose_the_others(self, caplog) -> None: + from neo4j_agent_memory.integrations.strands._retrieval import _retrieve_entries + from tests.unit.integrations.strands_fakes import FakeLongTerm + + long_term = FakeLongTerm() + long_term.entities = [_entity()] + long_term.fail_preferences = True + + rows = await _retrieve_entries( + long_term, "q", limit=10, min_score=0.2, + include_entities=True, include_preferences=True, include_facts=False, + nams=False, + ) + + assert [r.metadata["kind"] for r in rows] == ["entity"] + assert "preference search failed" in caplog.text.lower() + + @pytest.mark.asyncio + async def test_missing_score_is_omitted_not_zero(self) -> None: + from neo4j_agent_memory.integrations.strands._retrieval import _retrieve_entries + from tests.unit.integrations.strands_fakes import FakeLongTerm + + long_term = FakeLongTerm() + long_term.entities = [Entity(name="Acme Corp", type="ORGANIZATION")] # no similarity + + rows = await _retrieve_entries( + long_term, "q", limit=10, min_score=0.2, + include_entities=True, include_preferences=False, include_facts=False, + nams=True, + ) + + assert "score" not in rows[0].metadata From 1b416971af2b55357716ab65fe5b578b8c836746 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Wed, 19 Aug 2026 17:23:31 +0200 Subject: [PATCH 05/39] fix(strands): read similarity score for preference/fact rows too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bolt sets metadata["similarity"] on entities, preferences, and facts alike (search_entities/search_preferences/search_facts in long_term.py all set it), but _preference_row and _fact_row never read it — only _entity_row did. A consumer sorting or filtering on metadata["score"] would drop every preference and fact hit on bolt, indistinguishable from an unscored NAMS hit, skewing what a MemoryManager injects. Extracted the score-lookup into a shared _row() builder used by all three row mappers so the behavior can't drift apart again. --- .../integrations/strands/_retrieval.py | 33 +++++++++---------- .../test_strands_retrieval_entries.py | 27 ++++++++++++--- 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/src/neo4j_agent_memory/integrations/strands/_retrieval.py b/src/neo4j_agent_memory/integrations/strands/_retrieval.py index 7f426b37..392ebbfa 100644 --- a/src/neo4j_agent_memory/integrations/strands/_retrieval.py +++ b/src/neo4j_agent_memory/integrations/strands/_retrieval.py @@ -99,32 +99,29 @@ class _EntryRow: metadata: dict[str, Any] -def _entity_row(entity: Entity) -> _EntryRow: - metadata: dict[str, Any] = { - "kind": "entity", - "id": str(entity.id), - "type": entity.full_type or entity.type, - } - score = (entity.metadata or {}).get("similarity") +def _row( + kind: str, entry_id: Any, entry_type: str, source_metadata: dict[str, Any] | None, content: str +) -> _EntryRow: + metadata: dict[str, Any] = {"kind": kind, "id": str(entry_id), "type": entry_type} + score = (source_metadata or {}).get("similarity") if score is not None: - # NAMS never sets "similarity" — omit rather than default to 0, which - # would misrepresent an unscored hit as a bad match. + # Bolt sets "similarity" on entities, preferences, and facts alike; NAMS + # sets it on none. Omit rather than default to 0, which would misrepresent + # an unscored hit as a bad match. metadata["score"] = score - return _EntryRow(content=_format_entity(entity), metadata=metadata) + return _EntryRow(content=content, metadata=metadata) + + +def _entity_row(entity: Entity) -> _EntryRow: + return _row("entity", entity.id, entity.full_type or entity.type, entity.metadata, _format_entity(entity)) def _preference_row(preference: Preference) -> _EntryRow: - return _EntryRow( - content=_format_preference(preference), - metadata={"kind": "preference", "id": str(preference.id), "type": preference.category}, - ) + return _row("preference", preference.id, preference.category, preference.metadata, _format_preference(preference)) def _fact_row(fact: Fact) -> _EntryRow: - return _EntryRow( - content=_format_fact(fact), - metadata={"kind": "fact", "id": str(fact.id), "type": fact.predicate}, - ) + return _row("fact", fact.id, fact.predicate, fact.metadata, _format_fact(fact)) async def _retrieve_entries( diff --git a/tests/unit/integrations/test_strands_retrieval_entries.py b/tests/unit/integrations/test_strands_retrieval_entries.py index 8fa044e2..59b6c53f 100644 --- a/tests/unit/integrations/test_strands_retrieval_entries.py +++ b/tests/unit/integrations/test_strands_retrieval_entries.py @@ -15,6 +15,18 @@ def _entity() -> Entity: return e +def _preference() -> Preference: + p = Preference(category="ui", preference="dark mode") + p.metadata["similarity"] = 0.71 + return p + + +def _fact() -> Fact: + f = Fact(subject="Ada", predicate="works_at", object="Acme") + f.metadata["similarity"] = 0.55 + return f + + class TestRetrieveEntries: @pytest.mark.asyncio async def test_maps_each_kind_to_a_row_with_metadata(self) -> None: @@ -23,8 +35,8 @@ async def test_maps_each_kind_to_a_row_with_metadata(self) -> None: long_term = FakeLongTerm() long_term.entities = [_entity()] - long_term.preferences = [Preference(category="ui", preference="dark mode")] - long_term.facts = [Fact(subject="Ada", predicate="works_at", object="Acme")] + long_term.preferences = [_preference()] + long_term.facts = [_fact()] rows = await _retrieve_entries( long_term, @@ -43,6 +55,8 @@ async def test_maps_each_kind_to_a_row_with_metadata(self) -> None: assert rows[0].metadata["score"] == 0.83 assert rows[0].metadata["type"] == "ORGANIZATION" assert rows[1].metadata["type"] == "ui" + assert rows[1].metadata["score"] == 0.71 + assert rows[2].metadata["score"] == 0.55 assert "id" in rows[0].metadata @pytest.mark.asyncio @@ -88,11 +102,14 @@ async def test_missing_score_is_omitted_not_zero(self) -> None: long_term = FakeLongTerm() long_term.entities = [Entity(name="Acme Corp", type="ORGANIZATION")] # no similarity + long_term.preferences = [Preference(category="ui", preference="dark mode")] # no similarity + long_term.facts = [Fact(subject="Ada", predicate="works_at", object="Acme")] # no similarity rows = await _retrieve_entries( long_term, "q", limit=10, min_score=0.2, - include_entities=True, include_preferences=False, include_facts=False, - nams=True, + include_entities=True, include_preferences=True, include_facts=True, + nams=False, ) - assert "score" not in rows[0].metadata + assert [r.metadata["kind"] for r in rows] == ["entity", "preference", "fact"] + assert all("score" not in r.metadata for r in rows) From 5be895edae8e9c07c1ae3856dc6e12daf12dbbb0 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Wed, 19 Aug 2026 17:28:20 +0200 Subject: [PATCH 06/39] docs(spec): score is set for all three long-term kinds on bolt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The metadata table credited only search_entities with setting metadata["similarity"]. Bolt sets it on preferences and facts too (long_term.py:1126, :2065), which is why _preference_row and _fact_row were dropping it — fixed in 1b41697. --- .../superpowers/specs/2026-08-19-strands-memory-store-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md b/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md index 06b4b4e4..105a7670 100644 --- a/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md +++ b/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md @@ -130,7 +130,7 @@ the model sees: | `kind` | `entity` \| `preference` \| `fact` | | `id` | node id | | `type` | `full_type` for entities, `category` for preferences | -| `score` | similarity, bolt only — `search_entities` sets `entity.metadata["similarity"]`; NAMS returns none | +| `score` | similarity, bolt only — `search_entities`, `search_preferences` and `search_facts` each set `metadata["similarity"]` (`memory/long_term.py:1059`, `:1126`, `:2065`); NAMS sets none, and an absent score is omitted rather than zeroed | ### Both sinks on one class From 7443d540e6029bfdb4e0ae60a3cd2878f70e4d4d Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Wed, 19 Aug 2026 17:38:34 +0200 Subject: [PATCH 07/39] feat(strands): add Neo4jMemoryStore skeleton Config, protocol attributes, construction paths (borrowed vs owned client), for_nams, and an idempotent initialize. add/add_messages are deliberately undefined: a stub would flip _has_method's write-sink detection and change how extraction resolves before tasks 7/8 give those methods real behavior. --- .../integrations/strands/__init__.py | 6 + .../integrations/strands/memory_store.py | 142 ++++++++++++++++++ .../integrations/test_strands_memory_store.py | 99 ++++++++++++ 3 files changed, 247 insertions(+) create mode 100644 src/neo4j_agent_memory/integrations/strands/memory_store.py create mode 100644 tests/unit/integrations/test_strands_memory_store.py diff --git a/src/neo4j_agent_memory/integrations/strands/__init__.py b/src/neo4j_agent_memory/integrations/strands/__init__.py index eb25b6c9..c4ab9a6c 100644 --- a/src/neo4j_agent_memory/integrations/strands/__init__.py +++ b/src/neo4j_agent_memory/integrations/strands/__init__.py @@ -59,6 +59,10 @@ def llm_provider_from_strands(model: Any) -> LLMProvider: BEDROCK_LLM_MODELS, StrandsConfig, ) + from neo4j_agent_memory.integrations.strands.memory_store import ( + Neo4jMemoryStore, + Neo4jMemoryStoreConfig, + ) from neo4j_agent_memory.integrations.strands.session_manager import ( Neo4jRetrievalConfig, Neo4jSessionManager, @@ -79,6 +83,8 @@ def llm_provider_from_strands(model: Any) -> LLMProvider: "llm_provider_from_strands", "Neo4jSessionManager", "Neo4jRetrievalConfig", + "Neo4jMemoryStore", + "Neo4jMemoryStoreConfig", ] except ImportError: # strands-agents not installed diff --git a/src/neo4j_agent_memory/integrations/strands/memory_store.py b/src/neo4j_agent_memory/integrations/strands/memory_store.py new file mode 100644 index 00000000..55eaf1a6 --- /dev/null +++ b/src/neo4j_agent_memory/integrations/strands/memory_store.py @@ -0,0 +1,142 @@ +"""Strands ``MemoryStore`` backed by neo4j-agent-memory (bolt or NAMS). + +Long-term recall for the agent loop. Distinct from +:class:`Neo4jSessionManager`, which persists and restores the transcript — +see the design spec's positioning section. +""" + +from __future__ import annotations + +import logging +import uuid +from typing import TYPE_CHECKING + +from typing_extensions import Unpack + +try: + from strands.memory import MemoryStore, MemoryStoreConfig +except ImportError as import_error: # pragma: no cover - exercised via package __init__ + raise ImportError( + "strands-agents>=1.44.0 is required for the Strands memory store. " + "Install with: pip install 'neo4j-agent-memory[strands]'" + ) from import_error + +if TYPE_CHECKING: + from types import TracebackType + + from neo4j_agent_memory import MemoryClient, MemorySettings + +logger = logging.getLogger(__name__) + +#: Conversation-metadata key marking a conversation as a memory-store sink. +_STORE_KEY = "strands_memory_store" + +__all__ = ["Neo4jMemoryStore", "Neo4jMemoryStoreConfig"] + + +class Neo4jMemoryStoreConfig(MemoryStoreConfig, total=False): + """Configuration for :class:`Neo4jMemoryStore`. + + Extends Strands' ``MemoryStoreConfig`` (``name``, ``description``, + ``max_search_results``, ``writable``, ``extraction``) with the Neo4j + connection, scoping, and search knobs. + """ + + client: MemoryClient + settings: MemorySettings + conversation_id: str + user_id: str + include_entities: bool + include_preferences: bool + include_facts: bool + min_score: float + graph_tools: bool + + +class Neo4jMemoryStore(MemoryStore): + """Long-term memory recall and ingestion over a Neo4j context graph.""" + + def __init__(self, **store_config: Unpack[Neo4jMemoryStoreConfig]) -> None: + name = store_config.get("name") + if not name: + raise ValueError("Neo4jMemoryStore: 'name' is required and must be non-empty") + + client = store_config.get("client") + settings = store_config.get("settings") + if (client is None) == (settings is None): + raise ValueError( + "Neo4jMemoryStore: pass exactly one of 'client' (borrowed, left open) " + "or 'settings' (a client is constructed and owned by the store)" + ) + + self.name = name + self.description = store_config.get( + "description", f"Neo4j context graph '{name}': entities, preferences and facts." + ) + self.max_search_results = store_config.get("max_search_results") + self.writable = store_config.get("writable", True) + self.extraction = store_config.get("extraction", False) + + self.user_id = store_config.get("user_id") + self.graph_tools = store_config.get("graph_tools", True) + self._include_entities = store_config.get("include_entities", True) + self._include_preferences = store_config.get("include_preferences", True) + self._include_facts = store_config.get("include_facts", True) + self._min_score = store_config.get("min_score", 0.2) + + self._conversation_id = store_config.get("conversation_id") + self._sink_key: str | None = self._conversation_id + self._owns_client = client is None + self._run_id = uuid.uuid4().hex + self._written: set[tuple[str, int]] = set() + self._initialized = False + + if client is not None: + self._client: MemoryClient = client + else: + from neo4j_agent_memory import MemoryClient as _MemoryClient + + assert settings is not None + self._client = _MemoryClient(settings) + + @classmethod + def for_nams(cls, **store_config: Unpack[Neo4jMemoryStoreConfig]) -> Neo4jMemoryStore: + """Construct a store against hosted NAMS. + + Reads ``MEMORY_API_KEY`` (and optionally ``MEMORY_ENDPOINT``) from the + environment, mirroring :meth:`Neo4jSessionManager.for_nams`. + """ + if "client" not in store_config and "settings" not in store_config: + from neo4j_agent_memory import MemorySettings + + store_config["settings"] = MemorySettings(backend="nams") + return cls(**store_config) + + @property + def is_nams(self) -> bool: + return bool(getattr(self._client, "is_nams", False)) + + async def initialize(self) -> None: + """Connect the client and resolve the write sink. Idempotent.""" + if self._initialized: + return + if not getattr(self._client, "is_connected", False): + await self._client.connect() + self._initialized = True + + async def aclose(self) -> None: + """Close the client only when the store constructed it.""" + if self._owns_client: + await self._client.close() + + async def __aenter__(self) -> Neo4jMemoryStore: + await self.initialize() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + await self.aclose() diff --git a/tests/unit/integrations/test_strands_memory_store.py b/tests/unit/integrations/test_strands_memory_store.py new file mode 100644 index 00000000..bddf1812 --- /dev/null +++ b/tests/unit/integrations/test_strands_memory_store.py @@ -0,0 +1,99 @@ +"""Neo4jMemoryStore — construction, attributes, lifecycle.""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("strands", reason="strands-agents not installed") + +from tests.unit.integrations.strands_fakes import FakeMemoryClient + + +class TestConstruction: + def test_requires_a_name(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + with pytest.raises(ValueError, match="name"): + Neo4jMemoryStore(client=FakeMemoryClient()) # type: ignore[call-arg] + + def test_requires_exactly_one_of_client_or_settings(self) -> None: + from neo4j_agent_memory import MemorySettings + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + with pytest.raises(ValueError, match="exactly one"): + Neo4jMemoryStore(name="s") + with pytest.raises(ValueError, match="exactly one"): + Neo4jMemoryStore( + name="s", + client=FakeMemoryClient(), + settings=MemorySettings(neo4j={"password": "p"}), + ) + + def test_protocol_attribute_defaults(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + store = Neo4jMemoryStore(name="graph", client=FakeMemoryClient()) + + assert store.name == "graph" + assert store.writable is True + assert store.extraction is False + assert store.max_search_results is None + assert store.description is not None and "graph" in store.description.lower() + assert store.graph_tools is True + + def test_write_sinks_are_not_declared_yet(self) -> None: + """Scope guard: `add` lands in task 7, `add_messages` in task 8. + + `_has_method` compares `getattr(type(store), name)` against the Protocol's + own stub by identity, so an undefined method reads as absent. Stubbing + either sink here would flip write-sink detection before the methods do + anything — hence this asserts the intermediate state rather than the + final one. The both-sinks assertions live in task 8. + """ + from strands.memory.types import _has_method, _has_write_sink + + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + store = Neo4jMemoryStore(name="graph", client=FakeMemoryClient()) + + assert _has_method(store, "initialize") is True + assert _has_method(store, "add") is False + assert _has_method(store, "add_messages") is False + assert _has_write_sink(store) is False + + +class TestLifecycle: + @pytest.mark.asyncio + async def test_initialize_connects_an_owned_client_only(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient() + store = Neo4jMemoryStore(name="graph", client=client) + await store.initialize() + + assert client.connect_calls == 1 + await store.aclose() + assert client.close_calls == 0 # borrowed client stays open + + @pytest.mark.asyncio + async def test_initialize_is_idempotent(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient() + store = Neo4jMemoryStore(name="graph", client=client) + await store.initialize() + await store.initialize() + + assert client.connect_calls == 1 + + @pytest.mark.asyncio + async def test_context_manager_closes_an_owned_client(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient() + store = Neo4jMemoryStore(name="graph", client=client) + store._owns_client = True # simulate settings-constructed + async with store: + pass + + assert client.close_calls == 1 From a1ac791e72afc3a95345641d79e5efe71d37096d Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Wed, 19 Aug 2026 17:53:19 +0200 Subject: [PATCH 08/39] fix(strands): for_nams mirrors session manager, cover settings-owned path for_nams now reuses resolve_nams_connection/build_nams_settings instead of reimplementing them, so a missing MEMORY_API_KEY raises immediately with the same message, endpoint/api_key/transport_mode can be passed explicitly, and validate_on_connect defaults to False (Strands drives short synchronous bursts; skip the extra round-trip on connect). Add tests for the settings-owned construction path and the rewritten context-manager test, which previously monkey-patched _owns_client onto a client-constructed store and so could not catch a broken else branch. Trim initialize()'s docstring: it does not resolve a write sink yet. --- .../integrations/strands/memory_store.py | 23 ++++++--- .../integrations/test_strands_memory_store.py | 48 +++++++++++++++++-- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/src/neo4j_agent_memory/integrations/strands/memory_store.py b/src/neo4j_agent_memory/integrations/strands/memory_store.py index 55eaf1a6..4eb56178 100644 --- a/src/neo4j_agent_memory/integrations/strands/memory_store.py +++ b/src/neo4j_agent_memory/integrations/strands/memory_store.py @@ -25,6 +25,7 @@ from types import TracebackType from neo4j_agent_memory import MemoryClient, MemorySettings + from neo4j_agent_memory.nams.endpoints import TransportMode logger = logging.getLogger(__name__) @@ -100,16 +101,26 @@ def __init__(self, **store_config: Unpack[Neo4jMemoryStoreConfig]) -> None: self._client = _MemoryClient(settings) @classmethod - def for_nams(cls, **store_config: Unpack[Neo4jMemoryStoreConfig]) -> Neo4jMemoryStore: + def for_nams( + cls, + *, + endpoint: str | None = None, + api_key: str | None = None, + transport_mode: TransportMode = "auto", + **store_config: Unpack[Neo4jMemoryStoreConfig], + ) -> Neo4jMemoryStore: """Construct a store against hosted NAMS. Reads ``MEMORY_API_KEY`` (and optionally ``MEMORY_ENDPOINT``) from the - environment, mirroring :meth:`Neo4jSessionManager.for_nams`. + environment when not passed explicitly. """ - if "client" not in store_config and "settings" not in store_config: - from neo4j_agent_memory import MemorySettings + from neo4j_agent_memory.integrations.strands.config import ( + build_nams_settings, + resolve_nams_connection, + ) - store_config["settings"] = MemorySettings(backend="nams") + endpoint, api_key = resolve_nams_connection(endpoint, api_key) + store_config["settings"] = build_nams_settings(endpoint, api_key, transport_mode) return cls(**store_config) @property @@ -117,7 +128,7 @@ def is_nams(self) -> bool: return bool(getattr(self._client, "is_nams", False)) async def initialize(self) -> None: - """Connect the client and resolve the write sink. Idempotent.""" + """Connect the client if not already connected. Idempotent.""" if self._initialized: return if not getattr(self._client, "is_connected", False): diff --git a/tests/unit/integrations/test_strands_memory_store.py b/tests/unit/integrations/test_strands_memory_store.py index bddf1812..250bb8f6 100644 --- a/tests/unit/integrations/test_strands_memory_store.py +++ b/tests/unit/integrations/test_strands_memory_store.py @@ -41,6 +41,20 @@ def test_protocol_attribute_defaults(self) -> None: assert store.description is not None and "graph" in store.description.lower() assert store.graph_tools is True + def test_settings_construction_owns_the_client(self) -> None: + """Settings-constructed stores build and own a real MemoryClient. + + MemoryClient.__init__ is lazy (no connection, no embedder until + .connect()), so this is cheap and does not touch the network. + """ + from neo4j_agent_memory import MemoryClient, MemorySettings + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + store = Neo4jMemoryStore(name="graph", settings=MemorySettings(neo4j={"password": "p"})) + + assert store._owns_client is True + assert isinstance(store._client, MemoryClient) + def test_write_sinks_are_not_declared_yet(self) -> None: """Scope guard: `add` lands in task 7, `add_messages` in task 8. @@ -88,12 +102,38 @@ async def test_initialize_is_idempotent(self) -> None: @pytest.mark.asyncio async def test_context_manager_closes_an_owned_client(self) -> None: + """Ownership must come from the constructor (settings=), not a monkey-patched flag.""" + from unittest.mock import AsyncMock + + from neo4j_agent_memory import MemorySettings from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - client = FakeMemoryClient() - store = Neo4jMemoryStore(name="graph", client=client) - store._owns_client = True # simulate settings-constructed + store = Neo4jMemoryStore(name="graph", settings=MemorySettings(neo4j={"password": "p"})) + store._client.connect = AsyncMock() # type: ignore[method-assign] + store._client.close = AsyncMock() # type: ignore[method-assign] + async with store: pass - assert client.close_calls == 1 + store._client.close.assert_awaited_once() + + +class TestForNams: + def test_builds_nams_settings_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + monkeypatch.setenv("MEMORY_API_KEY", "test-key") + + store = Neo4jMemoryStore.for_nams(name="graph") + + settings = store._client._settings + assert settings.backend == "nams" + assert settings.nams.validate_on_connect is False + + def test_raises_without_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + monkeypatch.delenv("MEMORY_API_KEY", raising=False) + + with pytest.raises(ValueError, match="api_key is required"): + Neo4jMemoryStore.for_nams(name="graph") From 6973428305acdf4b05e8fda76bbfb44a155c2c7c Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Wed, 19 Aug 2026 18:02:47 +0200 Subject: [PATCH 09/39] feat(strands): resolve a deterministic memory-store sink conversation AddMessagesContext carries no session identity, so the store owns its write scope. The sink name is deterministic and matched via conversation metadata, so restarts reuse one sink instead of accumulating orphans on NAMS. --- .../integrations/strands/memory_store.py | 34 +++++++++ .../integrations/test_strands_memory_store.py | 72 +++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/src/neo4j_agent_memory/integrations/strands/memory_store.py b/src/neo4j_agent_memory/integrations/strands/memory_store.py index 4eb56178..779a0b09 100644 --- a/src/neo4j_agent_memory/integrations/strands/memory_store.py +++ b/src/neo4j_agent_memory/integrations/strands/memory_store.py @@ -127,6 +127,40 @@ def for_nams( def is_nams(self) -> bool: return bool(getattr(self._client, "is_nams", False)) + @property + def _sink_name(self) -> str: + """Deterministic sink name, stable across processes and restarts.""" + return f"strands-memory-store/{self.user_id or '_'}/{self.name}" + + async def _resolve_sink(self) -> str: + """Return the conversation key writes go to, creating the sink if needed. + + An explicit ``conversation_id`` is used verbatim. Otherwise the sink is + found by matching ``_STORE_KEY`` metadata against the deterministic sink + name — bolt keys conversations by ``session_id`` and NAMS mints its own + ids, so metadata is the portable handle. Same resolution strategy as + ``Neo4jSessionManager._aresolve_conversation``. + """ + if self._sink_key is not None: + return self._sink_key + + short_term = self._client.short_term + conversations = await short_term.list_conversations( + user_identifier=self.user_id, limit=1000 + ) + for conversation in conversations: + if (conversation.metadata or {}).get(_STORE_KEY) == self._sink_name: + self._sink_key = str(conversation.id) if self.is_nams else self._sink_name + return self._sink_key + + created = await short_term.create_conversation( + session_id=self._sink_name, + metadata={_STORE_KEY: self._sink_name, "session_type": "MEMORY_STORE"}, + user_identifier=self.user_id, + ) + self._sink_key = str(created.id) if self.is_nams else self._sink_name + return self._sink_key + async def initialize(self) -> None: """Connect the client if not already connected. Idempotent.""" if self._initialized: diff --git a/tests/unit/integrations/test_strands_memory_store.py b/tests/unit/integrations/test_strands_memory_store.py index 250bb8f6..8159ff6c 100644 --- a/tests/unit/integrations/test_strands_memory_store.py +++ b/tests/unit/integrations/test_strands_memory_store.py @@ -137,3 +137,75 @@ def test_raises_without_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None: with pytest.raises(ValueError, match="api_key is required"): Neo4jMemoryStore.for_nams(name="graph") + + +class TestSinkResolution: + @pytest.mark.asyncio + async def test_creates_a_deterministically_named_sink(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient() + store = Neo4jMemoryStore(name="graph", client=client, user_id="alice") + await store.initialize() + key = await store._resolve_sink() + + conv = client.short_term.conversations[key] + assert conv.session_id == "strands-memory-store/alice/graph" + assert conv.metadata["strands_memory_store"] == "strands-memory-store/alice/graph" + + @pytest.mark.asyncio + async def test_reuses_an_existing_sink_across_instances(self) -> None: + """Deterministic name + metadata match, so a restart does not mint a second sink.""" + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient() + first = Neo4jMemoryStore(name="graph", client=client) + await first.initialize() + key_one = await first._resolve_sink() + + second = Neo4jMemoryStore(name="graph", client=client) + await second.initialize() + key_two = await second._resolve_sink() + + assert key_one == key_two + assert len(client.short_term.conversations) == 1 + + @pytest.mark.asyncio + async def test_reuses_the_nams_server_minted_id_by_metadata(self) -> None: + """NAMS mints conversation ids, so reuse matches on metadata, not id.""" + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient(nams_mode=True) + first = Neo4jMemoryStore(name="graph", client=client) + await first.initialize() + key_one = await first._resolve_sink() + + second = Neo4jMemoryStore(name="graph", client=client) + await second.initialize() + key_two = await second._resolve_sink() + + assert key_one == key_two + assert len(client.short_term.conversations) == 1 + + @pytest.mark.asyncio + async def test_explicit_conversation_id_is_used_verbatim(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient() + store = Neo4jMemoryStore(name="graph", client=client, conversation_id="chat-42") + await store.initialize() + + assert await store._resolve_sink() == "chat-42" + assert client.short_term.conversations == {} # nothing minted + + @pytest.mark.asyncio + async def test_two_stores_with_different_names_get_different_sinks(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient() + personal = Neo4jMemoryStore(name="personal", client=client) + team = Neo4jMemoryStore(name="team", client=client) + await personal.initialize() + await team.initialize() + + assert await personal._resolve_sink() != await team._resolve_sink() From 52c3bb41531dc5dfd9c646ca3a03bfdcf97bac64 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Wed, 19 Aug 2026 18:06:30 +0200 Subject: [PATCH 10/39] fix(strands): skip the conversation list scan on bolt in _resolve_sink Bolt keys conversations by session_id and create_conversation is idempotent under it, so listing up to 1000 conversations before creating by deterministic name was a wasted round-trip on every store's first write. NAMS still lists and matches on _STORE_KEY metadata, since it mints its own conversation ids. --- .../integrations/strands/memory_store.py | 25 +++++++++++++------ .../integrations/test_strands_memory_store.py | 12 +++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/neo4j_agent_memory/integrations/strands/memory_store.py b/src/neo4j_agent_memory/integrations/strands/memory_store.py index 779a0b09..d0c861f8 100644 --- a/src/neo4j_agent_memory/integrations/strands/memory_store.py +++ b/src/neo4j_agent_memory/integrations/strands/memory_store.py @@ -135,22 +135,33 @@ def _sink_name(self) -> str: async def _resolve_sink(self) -> str: """Return the conversation key writes go to, creating the sink if needed. - An explicit ``conversation_id`` is used verbatim. Otherwise the sink is - found by matching ``_STORE_KEY`` metadata against the deterministic sink - name — bolt keys conversations by ``session_id`` and NAMS mints its own - ids, so metadata is the portable handle. Same resolution strategy as - ``Neo4jSessionManager._aresolve_conversation``. + An explicit ``conversation_id`` is used verbatim. Otherwise: bolt keys + conversations by ``session_id`` and ``create_conversation`` is idempotent + under it, so the deterministic sink name is created (or reused) directly + with no list round-trip. NAMS mints its own ids, so metadata is the only + portable handle — list and match ``_STORE_KEY`` metadata, else create. + Same split as ``Neo4jSessionManager._aresolve_conversation``. """ if self._sink_key is not None: return self._sink_key short_term = self._client.short_term + + if not self.is_nams: + await short_term.create_conversation( + session_id=self._sink_name, + metadata={_STORE_KEY: self._sink_name, "session_type": "MEMORY_STORE"}, + user_identifier=self.user_id, + ) + self._sink_key = self._sink_name + return self._sink_key + conversations = await short_term.list_conversations( user_identifier=self.user_id, limit=1000 ) for conversation in conversations: if (conversation.metadata or {}).get(_STORE_KEY) == self._sink_name: - self._sink_key = str(conversation.id) if self.is_nams else self._sink_name + self._sink_key = str(conversation.id) return self._sink_key created = await short_term.create_conversation( @@ -158,7 +169,7 @@ async def _resolve_sink(self) -> str: metadata={_STORE_KEY: self._sink_name, "session_type": "MEMORY_STORE"}, user_identifier=self.user_id, ) - self._sink_key = str(created.id) if self.is_nams else self._sink_name + self._sink_key = str(created.id) return self._sink_key async def initialize(self) -> None: diff --git a/tests/unit/integrations/test_strands_memory_store.py b/tests/unit/integrations/test_strands_memory_store.py index 8159ff6c..2151a86e 100644 --- a/tests/unit/integrations/test_strands_memory_store.py +++ b/tests/unit/integrations/test_strands_memory_store.py @@ -209,3 +209,15 @@ async def test_two_stores_with_different_names_get_different_sinks(self) -> None await team.initialize() assert await personal._resolve_sink() != await team._resolve_sink() + + @pytest.mark.asyncio + async def test_bolt_does_not_scan_conversations(self) -> None: + """On bolt the deterministic name is the key; a list scan would be wasted work.""" + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient() + store = Neo4jMemoryStore(name="graph", client=client) + await store.initialize() + await store._resolve_sink() + + assert client.short_term.list_conversations_calls == [] From 95de5f5c64dbc49d0f156f421ad2a3b8c689153c Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Wed, 19 Aug 2026 18:20:54 +0200 Subject: [PATCH 11/39] fix(strands): drop the pointless bolt create_conversation call in _resolve_sink Bolt's CREATE_CONVERSATION query has no metadata property and ShortTermMemory.create_conversation swallows metadata in **kwargs without forwarding it, so the eager create added in the previous fix could never tag a conversation as a memory-store sink. It was also redundant: add_message and add_messages_batch both auto-create the sink via _ensure_conversation on first write. Bolt now resolves to the deterministic name directly, with no backend call at all, matching Neo4jSessionManager._aresolve_conversation. FakeShortTerm.create_conversation now drops metadata in bolt mode too, so bolt-mode tests can't assert a tag a real bolt conversation would never carry. Strengthened the NAMS reuse test to assert the cached key is the minted uuid (not the deterministic name), and added a symmetric scan-count assertion for NAMS matching the existing zero-scan assertion for bolt. --- .../integrations/strands/memory_store.py | 22 +++++++-------- tests/unit/integrations/strands_fakes.py | 6 ++++- .../integrations/test_strands_memory_store.py | 27 +++++++++++++++---- 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/neo4j_agent_memory/integrations/strands/memory_store.py b/src/neo4j_agent_memory/integrations/strands/memory_store.py index d0c861f8..dc00344a 100644 --- a/src/neo4j_agent_memory/integrations/strands/memory_store.py +++ b/src/neo4j_agent_memory/integrations/strands/memory_store.py @@ -136,26 +136,24 @@ async def _resolve_sink(self) -> str: """Return the conversation key writes go to, creating the sink if needed. An explicit ``conversation_id`` is used verbatim. Otherwise: bolt keys - conversations by ``session_id`` and ``create_conversation`` is idempotent - under it, so the deterministic sink name is created (or reused) directly - with no list round-trip. NAMS mints its own ids, so metadata is the only - portable handle — list and match ``_STORE_KEY`` metadata, else create. - Same split as ``Neo4jSessionManager._aresolve_conversation``. + conversations by ``session_id`` and ``add_message``/``add_messages_batch`` + both auto-create the sink via ``_ensure_conversation`` on first write, so + the deterministic sink name *is* the whole contract — no backend call is + made here, and none is needed. Bolt's ``CREATE_CONVERSATION`` query also + has no metadata property, so tagging one is not possible even if we + called ``create_conversation`` eagerly. NAMS mints its own conversation + ids, so metadata is the only portable handle there — list and match + ``_STORE_KEY`` metadata, else create. Same split as + ``Neo4jSessionManager._aresolve_conversation``. """ if self._sink_key is not None: return self._sink_key - short_term = self._client.short_term - if not self.is_nams: - await short_term.create_conversation( - session_id=self._sink_name, - metadata={_STORE_KEY: self._sink_name, "session_type": "MEMORY_STORE"}, - user_identifier=self.user_id, - ) self._sink_key = self._sink_name return self._sink_key + short_term = self._client.short_term conversations = await short_term.list_conversations( user_identifier=self.user_id, limit=1000 ) diff --git a/tests/unit/integrations/strands_fakes.py b/tests/unit/integrations/strands_fakes.py index d2e1c4d8..538a0c47 100644 --- a/tests/unit/integrations/strands_fakes.py +++ b/tests/unit/integrations/strands_fakes.py @@ -32,10 +32,14 @@ async def create_conversation( ) -> Conversation: conv_id = uuid.uuid4() key = str(conv_id) if self._nams_mode else str(session_id) + # Real bolt's CREATE_CONVERSATION has no metadata property; only NAMS + # accepts and stores it. Mirror that so bolt-mode tests can't lean on + # metadata a real bolt conversation would never carry. + metadata = kwargs.get("metadata") or {} if self._nams_mode else {} conv = Conversation( id=conv_id, session_id=str(session_id), - metadata=kwargs.get("metadata") or {}, + metadata=metadata, ) self.conversations[key] = conv return conv diff --git a/tests/unit/integrations/test_strands_memory_store.py b/tests/unit/integrations/test_strands_memory_store.py index 2151a86e..ede9ed7c 100644 --- a/tests/unit/integrations/test_strands_memory_store.py +++ b/tests/unit/integrations/test_strands_memory_store.py @@ -142,6 +142,7 @@ def test_raises_without_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None: class TestSinkResolution: @pytest.mark.asyncio async def test_creates_a_deterministically_named_sink(self) -> None: + """Bolt keys conversations by session_id; the deterministic name is the whole contract.""" from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore client = FakeMemoryClient() @@ -149,13 +150,13 @@ async def test_creates_a_deterministically_named_sink(self) -> None: await store.initialize() key = await store._resolve_sink() - conv = client.short_term.conversations[key] - assert conv.session_id == "strands-memory-store/alice/graph" - assert conv.metadata["strands_memory_store"] == "strands-memory-store/alice/graph" + assert key == "strands-memory-store/alice/graph" + assert client.short_term.conversations == {} # nothing minted + assert client.short_term.list_conversations_calls == [] @pytest.mark.asyncio async def test_reuses_an_existing_sink_across_instances(self) -> None: - """Deterministic name + metadata match, so a restart does not mint a second sink.""" + """Bolt needs no round-trip for reuse: same name, same key, every time.""" from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore client = FakeMemoryClient() @@ -168,7 +169,8 @@ async def test_reuses_an_existing_sink_across_instances(self) -> None: key_two = await second._resolve_sink() assert key_one == key_two - assert len(client.short_term.conversations) == 1 + assert client.short_term.conversations == {} + assert client.short_term.list_conversations_calls == [] @pytest.mark.asyncio async def test_reuses_the_nams_server_minted_id_by_metadata(self) -> None: @@ -185,8 +187,23 @@ async def test_reuses_the_nams_server_minted_id_by_metadata(self) -> None: key_two = await second._resolve_sink() assert key_one == key_two + assert key_one != "strands-memory-store/_/graph" # the cached key is the minted uuid + only_conv = next(iter(client.short_term.conversations.values())) + assert key_one == str(only_conv.id) assert len(client.short_term.conversations) == 1 + @pytest.mark.asyncio + async def test_nams_scans_conversations_once(self) -> None: + """Symmetric to the bolt no-scan case: NAMS needs exactly one list round-trip.""" + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient(nams_mode=True) + store = Neo4jMemoryStore(name="graph", client=client) + await store.initialize() + await store._resolve_sink() + + assert len(client.short_term.list_conversations_calls) == 1 + @pytest.mark.asyncio async def test_explicit_conversation_id_is_used_verbatim(self) -> None: from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore From 078d4c2e7d5071408a415df4580c086de7c0b71a Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Wed, 19 Aug 2026 18:22:21 +0200 Subject: [PATCH 12/39] docs(spec): bolt cannot tag the sink conversation at all The scoping section said the sink is tagged "where the backend accepts conversation metadata at creation", implying bolt does so conditionally. It cannot: CREATE_CONVERSATION (graph/queries.py) has no metadata property and create_conversation drops the kwarg (memory/short_term.py:521-524). Replaced with the per-backend split the code actually implements: bolt resolves to the deterministic name with no backend call, relying on the first write's _ensure_conversation to create the conversation; NAMS matches _STORE_KEY metadata and caches the server-minted id. --- .../2026-08-19-strands-memory-store-design.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md b/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md index 105a7670..6ff66a77 100644 --- a/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md +++ b/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md @@ -144,13 +144,18 @@ still validates. One class per SDK, no per-transport split. `AddMessagesContext` carries no session identity, so the store owns its scope — matching the Bedrock KB precedent ("for per-tenant isolation, construct one store per scope"). -- Writes go to a dedicated sink conversation, minted in `initialize()` unless - `conversation_id` is given. Its session id is deterministic — +- Writes go to a dedicated sink conversation. Its name is deterministic — `strands-memory-store/{user_id or "_"}/{name}` — so restarts reuse one sink instead of - accumulating orphans. Where the backend accepts conversation metadata at creation the - sink is also tagged (mirroring `_SESSION_KEY` in the session manager); NAMS cannot set - metadata after creation, so the tag is best-effort and the deterministic id is the - contract. + accumulating orphans. Resolution splits by backend, as + `Neo4jSessionManager._aresolve_conversation` does: + - **bolt**: the deterministic name *is* the conversation key, and the first write + auto-creates the conversation (`add_message` and `add_messages_batch` both call + `_ensure_conversation`). So resolution makes no backend call. Nothing is tagged: bolt's + `CREATE_CONVERSATION` (`graph/queries.py`) has no metadata property, and + `create_conversation` drops a `metadata` kwarg (`memory/short_term.py:521-524`). + - **NAMS**: ids are server-minted and client session ids are dropped, so the sink is + found by matching `_STORE_KEY` metadata (accepted at creation, unsettable afterwards) + and the returned id is cached. - Reads are conversation-independent; only `user_id` narrows them. - Pointing the sink at the chat conversation duplicates `Message` nodes in the readable history: documented as unsupported, not guarded. From ab5823296d39bca79939e55768d12df65681351d Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Wed, 19 Aug 2026 18:30:32 +0200 Subject: [PATCH 13/39] feat(strands): implement Neo4jMemoryStore.search Long-term fan-out mapped to MemoryEntry, with kind/id/type/score metadata. Limit precedence: per-call option, then store default, then Strands' 3. Entities only on NAMS. --- .../integrations/strands/memory_store.py | 36 ++++++- tests/unit/integrations/strands_fakes.py | 4 + .../integrations/test_strands_memory_store.py | 96 +++++++++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) diff --git a/src/neo4j_agent_memory/integrations/strands/memory_store.py b/src/neo4j_agent_memory/integrations/strands/memory_store.py index dc00344a..ba06e87c 100644 --- a/src/neo4j_agent_memory/integrations/strands/memory_store.py +++ b/src/neo4j_agent_memory/integrations/strands/memory_store.py @@ -14,13 +14,15 @@ from typing_extensions import Unpack try: - from strands.memory import MemoryStore, MemoryStoreConfig + from strands.memory import MemoryEntry, MemoryStore, MemoryStoreConfig, SearchOptions except ImportError as import_error: # pragma: no cover - exercised via package __init__ raise ImportError( "strands-agents>=1.44.0 is required for the Strands memory store. " "Install with: pip install 'neo4j-agent-memory[strands]'" ) from import_error +from neo4j_agent_memory.integrations.strands._retrieval import _retrieve_entries + if TYPE_CHECKING: from types import TracebackType @@ -32,6 +34,10 @@ #: Conversation-metadata key marking a conversation as a memory-store sink. _STORE_KEY = "strands_memory_store" +#: Strands' own per-store default when neither caller nor store sets a limit +#: (mirrors ``strands.memory.memory_manager.DEFAULT_MAX_SEARCH_RESULTS``). +_DEFAULT_MAX_SEARCH_RESULTS = 3 + __all__ = ["Neo4jMemoryStore", "Neo4jMemoryStoreConfig"] @@ -178,6 +184,34 @@ async def initialize(self) -> None: await self._client.connect() self._initialized = True + async def search(self, query: str, options: SearchOptions | None = None) -> list[MemoryEntry]: + """Search long-term memory. No sink resolution: reads don't need one. + + Limit precedence: per-call option, then ``self.max_search_results``, + then Strands' own default. Per-kind failures are isolated in + ``_retrieve_entries``; a total failure here propagates so + ``MemoryManager.search`` can log a dead store rather than see an + empty, misleadingly-successful result. + """ + await self.initialize() + limit = (options or {}).get("max_search_results") + if limit is None: + limit = self.max_search_results + if limit is None: + limit = _DEFAULT_MAX_SEARCH_RESULTS + + rows = await _retrieve_entries( + self._client.long_term, + query, + limit=limit, + min_score=self._min_score, + include_entities=self._include_entities, + include_preferences=self._include_preferences, + include_facts=self._include_facts, + nams=self.is_nams, + ) + return [MemoryEntry(content=row.content, metadata=row.metadata) for row in rows] + async def aclose(self) -> None: """Close the client only when the store constructed it.""" if self._owns_client: diff --git a/tests/unit/integrations/strands_fakes.py b/tests/unit/integrations/strands_fakes.py index 538a0c47..41fdd417 100644 --- a/tests/unit/integrations/strands_fakes.py +++ b/tests/unit/integrations/strands_fakes.py @@ -103,6 +103,7 @@ def __init__(self) -> None: self.fail_preferences = False self.fail_facts = False self.search_calls: int = 0 + self.search_kwargs: list[dict[str, Any]] = [] async def _maybe_fail(self) -> None: if self.fail_searches: @@ -110,11 +111,13 @@ async def _maybe_fail(self) -> None: async def search_entities(self, query: str, **kwargs: Any) -> list[Any]: self.search_calls += 1 + self.search_kwargs.append({"query": query, **kwargs}) await self._maybe_fail() return self.entities async def search_preferences(self, query: str, **kwargs: Any) -> list[Any]: self.search_calls += 1 + self.search_kwargs.append({"query": query, **kwargs}) if self.fail_preferences: raise RuntimeError("preference backend down") await self._maybe_fail() @@ -122,6 +125,7 @@ async def search_preferences(self, query: str, **kwargs: Any) -> list[Any]: async def search_facts(self, query: str, **kwargs: Any) -> list[Any]: self.search_calls += 1 + self.search_kwargs.append({"query": query, **kwargs}) if self.fail_facts: raise RuntimeError("fact backend down") await self._maybe_fail() diff --git a/tests/unit/integrations/test_strands_memory_store.py b/tests/unit/integrations/test_strands_memory_store.py index ede9ed7c..11336cc3 100644 --- a/tests/unit/integrations/test_strands_memory_store.py +++ b/tests/unit/integrations/test_strands_memory_store.py @@ -238,3 +238,99 @@ async def test_bolt_does_not_scan_conversations(self) -> None: await store._resolve_sink() assert client.short_term.list_conversations_calls == [] + + +class TestSearch: + @pytest.mark.asyncio + async def test_returns_memory_entries_with_metadata(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + from neo4j_agent_memory.memory.long_term import Entity, Preference + + client = FakeMemoryClient() + entity = Entity(name="Acme Corp", type="ORGANIZATION") + entity.metadata["similarity"] = 0.9 + client.long_term.entities = [entity] + client.long_term.preferences = [Preference(category="ui", preference="dark mode")] + + store = Neo4jMemoryStore(name="graph", client=client) + await store.initialize() + entries = await store.search("acme") + + assert [e.content for e in entries] == [ + "[entity] Acme Corp (ORGANIZATION)", + "[preference] ui: dark mode", + ] + assert entries[0].metadata is not None + assert entries[0].metadata["kind"] == "entity" + assert entries[0].metadata["score"] == 0.9 + + @pytest.mark.asyncio + async def test_limit_precedence_call_then_store_then_default(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient() + store = Neo4jMemoryStore( + name="graph", client=client, include_preferences=False, include_facts=False + ) + await store.initialize() + + await store.search("q") + assert client.long_term.search_kwargs[-1]["limit"] == 3 # protocol default + + store.max_search_results = 7 + await store.search("q") + assert client.long_term.search_kwargs[-1]["limit"] == 7 + + await store.search("q", {"max_search_results": 2}) + assert client.long_term.search_kwargs[-1]["limit"] == 2 + + @pytest.mark.asyncio + async def test_kind_flags_are_honoured(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + from neo4j_agent_memory.memory.long_term import Preference + + client = FakeMemoryClient() + client.long_term.preferences = [Preference(category="ui", preference="dark mode")] + + store = Neo4jMemoryStore( + name="graph", + client=client, + include_entities=False, + include_preferences=True, + include_facts=False, + ) + await store.initialize() + entries = await store.search("q") + + assert len(entries) == 1 + assert entries[0].metadata is not None + assert entries[0].metadata["kind"] == "preference" + + @pytest.mark.asyncio + async def test_nams_returns_entities_only(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + from neo4j_agent_memory.memory.long_term import Entity, Preference + + client = FakeMemoryClient(nams_mode=True) + client.long_term.entities = [Entity(name="Acme Corp", type="ORGANIZATION")] + client.long_term.preferences = [Preference(category="ui", preference="dark mode")] + + store = Neo4jMemoryStore(name="graph", client=client) + await store.initialize() + entries = await store.search("q") + + assert len(entries) == 1 + assert entries[0].metadata is not None + assert entries[0].metadata["kind"] == "entity" + + @pytest.mark.asyncio + async def test_search_does_not_mint_a_sink(self) -> None: + """Reads are conversation-independent; only writes need the sink.""" + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient() + store = Neo4jMemoryStore(name="graph", client=client) + await store.initialize() + await store.search("q") + + assert client.short_term.conversations == {} From 77245698123d7825424d588c47a97515cfff53bc Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Wed, 19 Aug 2026 18:40:12 +0200 Subject: [PATCH 14/39] test(strands): fix round 1 on Neo4jMemoryStore.search tests Four of the search() tests were passing for the wrong reasons: - test_kind_flags_are_honoured only populated the enabled kind, so a dropped include_* kwarg in the implementation would sail through. Now populates all three kinds and asserts search_calls == 1. - test_search_does_not_mint_a_sink used the bolt fake, where _resolve_sink() never calls the backend regardless of whether search() calls it. Switched to nams_mode=True, where an accidental _resolve_sink() call is observable via list_conversations_calls. - Limit precedence test didn't cover max_search_results=0, which an `if not limit` rewrite would silently break. - No test exercised search()'s own initialize() call without a prior explicit initialize() -- added one asserting connect_calls == 1. Implementation unchanged; all fixes are to test bodies only. --- .../integrations/test_strands_memory_store.py | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/tests/unit/integrations/test_strands_memory_store.py b/tests/unit/integrations/test_strands_memory_store.py index 11336cc3..83379316 100644 --- a/tests/unit/integrations/test_strands_memory_store.py +++ b/tests/unit/integrations/test_strands_memory_store.py @@ -284,13 +284,20 @@ async def test_limit_precedence_call_then_store_then_default(self) -> None: await store.search("q", {"max_search_results": 2}) assert client.long_term.search_kwargs[-1]["limit"] == 2 + # Explicit 0 must stay 0, not fall through to a truthiness check. + await store.search("q", {"max_search_results": 0}) + assert client.long_term.search_kwargs[-1]["limit"] == 0 + @pytest.mark.asyncio async def test_kind_flags_are_honoured(self) -> None: + """All three kinds have data; only the enabled one should ever be searched.""" from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - from neo4j_agent_memory.memory.long_term import Preference + from neo4j_agent_memory.memory.long_term import Entity, Fact, Preference client = FakeMemoryClient() + client.long_term.entities = [Entity(name="Acme Corp", type="ORGANIZATION")] client.long_term.preferences = [Preference(category="ui", preference="dark mode")] + client.long_term.facts = [Fact(subject="Acme", predicate="located_in", object="NYC")] store = Neo4jMemoryStore( name="graph", @@ -305,6 +312,10 @@ async def test_kind_flags_are_honoured(self) -> None: assert len(entries) == 1 assert entries[0].metadata is not None assert entries[0].metadata["kind"] == "preference" + # Proves entities/facts were never searched, not merely that they + # returned nothing (a dropped include_* kwarg would sail through + # on len(entries) == 1 alone). + assert client.long_term.search_calls == 1 @pytest.mark.asyncio async def test_nams_returns_entities_only(self) -> None: @@ -325,12 +336,31 @@ async def test_nams_returns_entities_only(self) -> None: @pytest.mark.asyncio async def test_search_does_not_mint_a_sink(self) -> None: - """Reads are conversation-independent; only writes need the sink.""" + """Reads are conversation-independent; only writes need the sink. + + Must use nams_mode=True: on bolt, _resolve_sink() makes no backend + call at all (it just sets self._sink_key locally), so an accidental + _resolve_sink() call from search() would be invisible there. NAMS + genuinely calls list_conversations (and possibly create_conversation), + so this is the only client mode that can catch that regression. + """ from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - client = FakeMemoryClient() + client = FakeMemoryClient(nams_mode=True) store = Neo4jMemoryStore(name="graph", client=client) await store.initialize() await store.search("q") + assert client.short_term.list_conversations_calls == [] assert client.short_term.conversations == {} + + @pytest.mark.asyncio + async def test_search_initializes_without_a_prior_initialize_call(self) -> None: + """Standalone use (no MemoryManager.init_agent) must still connect.""" + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient() + store = Neo4jMemoryStore(name="graph", client=client) + await store.search("q") + + assert client.connect_calls == 1 From 31fd7dfbedc1223d6fe89926014a0a71ece75889 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Wed, 19 Aug 2026 18:58:05 +0200 Subject: [PATCH 15/39] feat(strands): implement Neo4jMemoryStore.add Default sink is a message written with extraction, the one path every backend supports. metadata['kind'] routes preference/fact/entity writes, falling back to the sink where NAMS does not expose the endpoint. --- .../integrations/strands/memory_store.py | 77 +++++++++++- tests/unit/integrations/strands_fakes.py | 37 ++++++ .../integrations/test_strands_memory_store.py | 117 ++++++++++++++++-- 3 files changed, 222 insertions(+), 9 deletions(-) diff --git a/src/neo4j_agent_memory/integrations/strands/memory_store.py b/src/neo4j_agent_memory/integrations/strands/memory_store.py index ba06e87c..3745c8a6 100644 --- a/src/neo4j_agent_memory/integrations/strands/memory_store.py +++ b/src/neo4j_agent_memory/integrations/strands/memory_store.py @@ -9,7 +9,7 @@ import logging import uuid -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from typing_extensions import Unpack @@ -21,6 +21,7 @@ "Install with: pip install 'neo4j-agent-memory[strands]'" ) from import_error +from neo4j_agent_memory.core.exceptions import NotSupportedError from neo4j_agent_memory.integrations.strands._retrieval import _retrieve_entries if TYPE_CHECKING: @@ -212,6 +213,80 @@ async def search(self, query: str, options: SearchOptions | None = None) -> list ) return [MemoryEntry(content=row.content, metadata=row.metadata) for row in rows] + async def add(self, content: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]: + """Add one piece of content. + + Default sink: a message in the store's conversation, written with + extraction on — the one path available on every backend. ``metadata["kind"]`` + opts into a typed write (``preference`` / ``fact`` / ``entity``); on a + backend that does not expose it, the write falls back to the default sink + so the memory is never silently dropped. + + Extraction writes are at-least-once, so this tolerates duplicates. + """ + if not self.writable: + raise ValueError( + f"Neo4jMemoryStore '{self.name}': store is not writable. " + "Set writable=True to enable add()." + ) + if not content.strip(): + raise ValueError(f"Neo4jMemoryStore '{self.name}': content must not be empty") + + await self.initialize() + meta = metadata or {} + kind = meta.get("kind") + + if kind in ("preference", "fact", "entity"): + try: + return await self._add_typed(kind, content, meta) + except NotSupportedError as error: + logger.warning( + "Neo4jMemoryStore '%s': %s unsupported on this backend (%s); " + "falling back to the message sink.", + self.name, + kind, + error, + ) + + return await self._add_to_sink(content) + + async def _add_typed(self, kind: str, content: str, meta: dict[str, Any]) -> dict[str, Any]: + long_term = self._client.long_term + if kind == "preference": + preference = await long_term.add_preference(meta.get("category", "memory"), content) + return {"kind": "preference", "id": str(preference.id)} + if kind == "fact": + subject, predicate, obj = ( + meta.get("subject"), + meta.get("predicate"), + meta.get("object"), + ) + if not (subject and predicate and obj): + raise ValueError( + f"Neo4jMemoryStore '{self.name}': kind='fact' requires " + "subject, predicate and object in metadata" + ) + fact = await long_term.add_fact(subject, predicate, obj) + return {"kind": "fact", "id": str(fact.id)} + # add_entity returns (Entity, DeduplicationResult) on bolt but a bare + # Entity on NAMS (no dedup pipeline there). + entity_result = await long_term.add_entity( + meta.get("name", content), meta.get("type", "OBJECT") + ) + entity = entity_result[0] if isinstance(entity_result, tuple) else entity_result + return {"kind": "entity", "id": str(entity.id)} + + async def _add_to_sink(self, content: str) -> dict[str, Any]: + sink = await self._resolve_sink() + message = await self._client.short_term.add_message( + sink, + "user", + content, + extract_entities=True, + user_identifier=self.user_id, + ) + return {"kind": "message", "id": str(message.id)} + async def aclose(self) -> None: """Close the client only when the store constructed it.""" if self._owns_client: diff --git a/tests/unit/integrations/strands_fakes.py b/tests/unit/integrations/strands_fakes.py index 41fdd417..b43eaaed 100644 --- a/tests/unit/integrations/strands_fakes.py +++ b/tests/unit/integrations/strands_fakes.py @@ -104,11 +104,25 @@ def __init__(self) -> None: self.fail_facts = False self.search_calls: int = 0 self.search_kwargs: list[dict[str, Any]] = [] + self.added_preferences: list[tuple[str, str]] = [] + self.added_facts: list[tuple[str, str, str]] = [] + self.added_entities: list[tuple[str, str]] = [] + self.nams_mode = False async def _maybe_fail(self) -> None: if self.fail_searches: raise RuntimeError("search backend down") + def _reject_on_nams(self, method: str) -> None: + if self.nams_mode: + from neo4j_agent_memory.core.exceptions import NotSupportedError + + raise NotSupportedError( + backend="nams", + method=f"LongTermMemory.{method}", + message="NAMS provides entity endpoints only.", + ) + async def search_entities(self, query: str, **kwargs: Any) -> list[Any]: self.search_calls += 1 self.search_kwargs.append({"query": query, **kwargs}) @@ -118,6 +132,7 @@ async def search_entities(self, query: str, **kwargs: Any) -> list[Any]: async def search_preferences(self, query: str, **kwargs: Any) -> list[Any]: self.search_calls += 1 self.search_kwargs.append({"query": query, **kwargs}) + self._reject_on_nams("search_preferences") if self.fail_preferences: raise RuntimeError("preference backend down") await self._maybe_fail() @@ -126,11 +141,32 @@ async def search_preferences(self, query: str, **kwargs: Any) -> list[Any]: async def search_facts(self, query: str, **kwargs: Any) -> list[Any]: self.search_calls += 1 self.search_kwargs.append({"query": query, **kwargs}) + self._reject_on_nams("search_facts") if self.fail_facts: raise RuntimeError("fact backend down") await self._maybe_fail() return self.facts + async def add_preference(self, category: str, preference: str, **kwargs: Any) -> Any: + self._reject_on_nams("add_preference") + self.added_preferences.append((category, preference)) + from neo4j_agent_memory.memory.long_term import Preference + + return Preference(category=category, preference=preference) + + async def add_fact(self, subject: str, predicate: str, obj: str, **kwargs: Any) -> Any: + self._reject_on_nams("add_fact") + self.added_facts.append((subject, predicate, obj)) + from neo4j_agent_memory.memory.long_term import Fact + + return Fact(subject=subject, predicate=predicate, object=obj) + + async def add_entity(self, name: str, entity_type: str, **kwargs: Any) -> Any: + from neo4j_agent_memory.memory.long_term import Entity + + self.added_entities.append((name, entity_type)) + return Entity(name=name, type=entity_type), None + class FakeReasoning: def __init__(self) -> None: @@ -170,6 +206,7 @@ def __init__(self, nams_mode: bool = False) -> None: self._nams_mode = nams_mode self.short_term = FakeShortTerm(nams_mode) self.long_term = FakeLongTerm() + self.long_term.nams_mode = nams_mode self.reasoning = FakeReasoning() self.connect_calls = 0 self.close_calls = 0 diff --git a/tests/unit/integrations/test_strands_memory_store.py b/tests/unit/integrations/test_strands_memory_store.py index 83379316..0e6bcadb 100644 --- a/tests/unit/integrations/test_strands_memory_store.py +++ b/tests/unit/integrations/test_strands_memory_store.py @@ -55,14 +55,13 @@ def test_settings_construction_owns_the_client(self) -> None: assert store._owns_client is True assert isinstance(store._client, MemoryClient) - def test_write_sinks_are_not_declared_yet(self) -> None: - """Scope guard: `add` lands in task 7, `add_messages` in task 8. + def test_add_messages_is_not_declared_yet(self) -> None: + """Scope guard: `add` landed in task 7, `add_messages` lands in task 8. `_has_method` compares `getattr(type(store), name)` against the Protocol's - own stub by identity, so an undefined method reads as absent. Stubbing - either sink here would flip write-sink detection before the methods do - anything — hence this asserts the intermediate state rather than the - final one. The both-sinks assertions live in task 8. + own stub by identity, so an undefined method reads as absent. `add` is now + real, so `_has_write_sink` is already True; the both-sinks assertion lives + in task 8. """ from strands.memory.types import _has_method, _has_write_sink @@ -71,9 +70,9 @@ def test_write_sinks_are_not_declared_yet(self) -> None: store = Neo4jMemoryStore(name="graph", client=FakeMemoryClient()) assert _has_method(store, "initialize") is True - assert _has_method(store, "add") is False + assert _has_method(store, "add") is True assert _has_method(store, "add_messages") is False - assert _has_write_sink(store) is False + assert _has_write_sink(store) is True class TestLifecycle: @@ -364,3 +363,105 @@ async def test_search_initializes_without_a_prior_initialize_call(self) -> None: await store.search("q") assert client.connect_calls == 1 + + +class TestAdd: + @pytest.mark.asyncio + async def test_default_writes_a_message_into_the_sink_with_extraction(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient() + store = Neo4jMemoryStore(name="graph", client=client) + await store.initialize() + result = await store.add("The user prefers dark mode") + + call = client.short_term.add_message_calls[-1] + assert call["content"] == "The user prefers dark mode" + assert call["role"] == "user" + assert call["extract_entities"] is True + assert result["kind"] == "message" + + @pytest.mark.asyncio + async def test_kind_preference_routes_to_add_preference(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient() + store = Neo4jMemoryStore(name="graph", client=client) + await store.initialize() + result = await store.add("dark mode", {"kind": "preference", "category": "ui"}) + + assert client.long_term.added_preferences == [("ui", "dark mode")] + assert result["kind"] == "preference" + assert client.short_term.add_message_calls == [] + + @pytest.mark.asyncio + async def test_kind_preference_without_category_defaults_to_memory(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient() + store = Neo4jMemoryStore(name="graph", client=client) + await store.initialize() + await store.add("dark mode", {"kind": "preference"}) + + assert client.long_term.added_preferences == [("memory", "dark mode")] + + @pytest.mark.asyncio + async def test_kind_fact_requires_a_triple(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient() + store = Neo4jMemoryStore(name="graph", client=client) + await store.initialize() + + await store.add( + "Ada works at Acme", + {"kind": "fact", "subject": "Ada", "predicate": "works_at", "object": "Acme"}, + ) + assert client.long_term.added_facts == [("Ada", "works_at", "Acme")] + + with pytest.raises(ValueError, match="subject.*predicate.*object"): + await store.add("Ada works at Acme", {"kind": "fact"}) + + @pytest.mark.asyncio + async def test_kind_entity_routes_to_add_entity(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient() + store = Neo4jMemoryStore(name="graph", client=client) + await store.initialize() + await store.add("Acme Corp", {"kind": "entity", "type": "ORGANIZATION"}) + + assert client.long_term.added_entities == [("Acme Corp", "ORGANIZATION")] + + @pytest.mark.asyncio + async def test_unsupported_kind_on_nams_falls_back_to_the_sink(self, caplog) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient(nams_mode=True) + store = Neo4jMemoryStore(name="graph", client=client) + await store.initialize() + result = await store.add("dark mode", {"kind": "preference", "category": "ui"}) + + assert result["kind"] == "message" + assert client.short_term.add_message_calls[-1]["content"] == "dark mode" + assert "falling back" in caplog.text.lower() + + @pytest.mark.asyncio + async def test_rejects_writes_when_not_writable(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + store = Neo4jMemoryStore(name="graph", client=FakeMemoryClient(), writable=False) + await store.initialize() + + with pytest.raises(ValueError, match="not writable"): + await store.add("anything") + + @pytest.mark.asyncio + async def test_rejects_empty_content(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + store = Neo4jMemoryStore(name="graph", client=FakeMemoryClient()) + await store.initialize() + + with pytest.raises(ValueError, match="empty"): + await store.add(" ") From 32b3c502ace95b0fbdd39419ca8a9989b1df4f40 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Wed, 19 Aug 2026 19:11:44 +0200 Subject: [PATCH 16/39] fix(strands): fix round 1 on Neo4jMemoryStore.add - FakeLongTerm.add_entity now mirrors NAMS's bare-Entity return (vs. bolt's (Entity, DeduplicationResult) tuple), exercising the isinstance narrowing in _add_typed for the case it exists for. - Add tests: entity kind on NAMS, entity name/type defaults, no dual write on entity routing. - _add_typed's entity branch is now an explicit `if kind == "entity"` with a ValueError on an unmatched kind, instead of a silent fall-through. - The unsupported-kind fallback warning is now logged at most once per store per kind, not once per call. --- .../integrations/strands/memory_store.py | 35 ++++++++------ tests/unit/integrations/strands_fakes.py | 7 ++- .../integrations/test_strands_memory_store.py | 46 +++++++++++++++++++ 3 files changed, 73 insertions(+), 15 deletions(-) diff --git a/src/neo4j_agent_memory/integrations/strands/memory_store.py b/src/neo4j_agent_memory/integrations/strands/memory_store.py index 3745c8a6..19d42751 100644 --- a/src/neo4j_agent_memory/integrations/strands/memory_store.py +++ b/src/neo4j_agent_memory/integrations/strands/memory_store.py @@ -98,6 +98,7 @@ def __init__(self, **store_config: Unpack[Neo4jMemoryStoreConfig]) -> None: self._run_id = uuid.uuid4().hex self._written: set[tuple[str, int]] = set() self._initialized = False + self._warned_unsupported_kinds: set[str] = set() if client is not None: self._client: MemoryClient = client @@ -240,13 +241,17 @@ async def add(self, content: str, metadata: dict[str, Any] | None = None) -> dic try: return await self._add_typed(kind, content, meta) except NotSupportedError as error: - logger.warning( - "Neo4jMemoryStore '%s': %s unsupported on this backend (%s); " - "falling back to the message sink.", - self.name, - kind, - error, - ) + if kind not in self._warned_unsupported_kinds: + self._warned_unsupported_kinds.add(kind) + logger.warning( + "Neo4jMemoryStore '%s': %s unsupported on this backend (%s); " + "falling back to the message sink. (This warning is logged " + "once per store; further %s writes fall back silently.)", + self.name, + kind, + error, + kind, + ) return await self._add_to_sink(content) @@ -268,13 +273,15 @@ async def _add_typed(self, kind: str, content: str, meta: dict[str, Any]) -> dic ) fact = await long_term.add_fact(subject, predicate, obj) return {"kind": "fact", "id": str(fact.id)} - # add_entity returns (Entity, DeduplicationResult) on bolt but a bare - # Entity on NAMS (no dedup pipeline there). - entity_result = await long_term.add_entity( - meta.get("name", content), meta.get("type", "OBJECT") - ) - entity = entity_result[0] if isinstance(entity_result, tuple) else entity_result - return {"kind": "entity", "id": str(entity.id)} + if kind == "entity": + # add_entity returns (Entity, DeduplicationResult) on bolt but a bare + # Entity on NAMS (no dedup pipeline there). + entity_result = await long_term.add_entity( + meta.get("name", content), meta.get("type", "OBJECT") + ) + entity = entity_result[0] if isinstance(entity_result, tuple) else entity_result + return {"kind": "entity", "id": str(entity.id)} + raise ValueError(f"Neo4jMemoryStore '{self.name}': unknown kind '{kind}'") async def _add_to_sink(self, content: str) -> dict[str, Any]: sink = await self._resolve_sink() diff --git a/tests/unit/integrations/strands_fakes.py b/tests/unit/integrations/strands_fakes.py index b43eaaed..5a1bc288 100644 --- a/tests/unit/integrations/strands_fakes.py +++ b/tests/unit/integrations/strands_fakes.py @@ -165,7 +165,12 @@ async def add_entity(self, name: str, entity_type: str, **kwargs: Any) -> Any: from neo4j_agent_memory.memory.long_term import Entity self.added_entities.append((name, entity_type)) - return Entity(name=name, type=entity_type), None + entity = Entity(name=name, type=entity_type) + # Real NAMS add_entity returns a bare Entity (nams/long_term.py:205-210); + # bolt returns (Entity, DeduplicationResult). + if self.nams_mode: + return entity + return entity, None class FakeReasoning: diff --git a/tests/unit/integrations/test_strands_memory_store.py b/tests/unit/integrations/test_strands_memory_store.py index 0e6bcadb..eca41eb3 100644 --- a/tests/unit/integrations/test_strands_memory_store.py +++ b/tests/unit/integrations/test_strands_memory_store.py @@ -432,6 +432,36 @@ async def test_kind_entity_routes_to_add_entity(self) -> None: await store.add("Acme Corp", {"kind": "entity", "type": "ORGANIZATION"}) assert client.long_term.added_entities == [("Acme Corp", "ORGANIZATION")] + assert client.short_term.add_message_calls == [] + + @pytest.mark.asyncio + async def test_kind_entity_defaults_name_and_type(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient() + store = Neo4jMemoryStore(name="graph", client=client) + await store.initialize() + await store.add("Acme Corp", {"kind": "entity"}) + + assert client.long_term.added_entities == [("Acme Corp", "OBJECT")] + + @pytest.mark.asyncio + async def test_kind_entity_on_nams_returns_bare_entity_id(self) -> None: + """NAMS add_entity returns a bare Entity, not a (Entity, Dedup) tuple. + + Without the isinstance narrowing in `_add_typed`, this raises trying + to subscript a bare Entity as a tuple. + """ + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient(nams_mode=True) + store = Neo4jMemoryStore(name="graph", client=client) + await store.initialize() + result = await store.add("Acme Corp", {"kind": "entity", "type": "ORGANIZATION"}) + + assert client.long_term.added_entities == [("Acme Corp", "ORGANIZATION")] + assert result["kind"] == "entity" + assert result["id"] @pytest.mark.asyncio async def test_unsupported_kind_on_nams_falls_back_to_the_sink(self, caplog) -> None: @@ -446,6 +476,22 @@ async def test_unsupported_kind_on_nams_falls_back_to_the_sink(self, caplog) -> assert client.short_term.add_message_calls[-1]["content"] == "dark mode" assert "falling back" in caplog.text.lower() + @pytest.mark.asyncio + async def test_unsupported_kind_warning_is_logged_once_per_store(self, caplog) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient(nams_mode=True) + store = Neo4jMemoryStore(name="graph", client=client) + await store.initialize() + + with caplog.at_level("WARNING"): + await store.add("dark mode", {"kind": "preference", "category": "ui"}) + await store.add("another one", {"kind": "preference", "category": "ui"}) + + warnings = [r for r in caplog.records if "unsupported on this backend" in r.message] + assert len(warnings) == 1 + assert len(client.short_term.add_message_calls) == 2 + @pytest.mark.asyncio async def test_rejects_writes_when_not_writable(self) -> None: from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore From e82cdbf8d7183f8f41f3cf0edbe003f13d6cb652 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Wed, 19 Aug 2026 19:23:14 +0200 Subject: [PATCH 17/39] feat(strands): implement Neo4jMemoryStore.add_messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side extraction sink: the filtered batch goes straight to bulk_add_messages, chunked at 100. At-least-once retries are deduped in-process on (run_id, sequence_number), since sequence numbers reset each run and message writes have no durable idempotency key. Widened ShortTermProtocol.bulk_add_messages to accept **kwargs, matching what both the bolt and NAMS implementations already accept and forward — the narrower Protocol signature was a pre-existing gap that add_messages's extract_entities/user_identifier call exposed. --- src/neo4j_agent_memory/core/protocols.py | 7 +- .../integrations/strands/memory_store.py | 68 ++++++- tests/unit/integrations/strands_fakes.py | 16 ++ .../integrations/test_strands_memory_store.py | 169 ++++++++++++++++-- 4 files changed, 239 insertions(+), 21 deletions(-) diff --git a/src/neo4j_agent_memory/core/protocols.py b/src/neo4j_agent_memory/core/protocols.py index 250b27bc..8de46121 100644 --- a/src/neo4j_agent_memory/core/protocols.py +++ b/src/neo4j_agent_memory/core/protocols.py @@ -187,8 +187,13 @@ async def bulk_add_messages( self, session_id: str, messages: list[dict[str, Any]], + **kwargs: Any, ) -> list[Message]: - """Bulk-insert messages for a session in one round-trip, preserving order.""" + """Bulk-insert messages for a session in one round-trip, preserving order. + + ``**kwargs`` forwards backend-specific knobs (``extract_entities``, + ``user_identifier``, …) — both implementations already accept them. + """ ... async def get_observations( diff --git a/src/neo4j_agent_memory/integrations/strands/memory_store.py b/src/neo4j_agent_memory/integrations/strands/memory_store.py index 19d42751..c48395e2 100644 --- a/src/neo4j_agent_memory/integrations/strands/memory_store.py +++ b/src/neo4j_agent_memory/integrations/strands/memory_store.py @@ -14,7 +14,13 @@ from typing_extensions import Unpack try: - from strands.memory import MemoryEntry, MemoryStore, MemoryStoreConfig, SearchOptions + from strands.memory import ( + AddMessagesContext, + MemoryEntry, + MemoryStore, + MemoryStoreConfig, + SearchOptions, + ) except ImportError as import_error: # pragma: no cover - exercised via package __init__ raise ImportError( "strands-agents>=1.44.0 is required for the Strands memory store. " @@ -22,11 +28,14 @@ ) from import_error from neo4j_agent_memory.core.exceptions import NotSupportedError +from neo4j_agent_memory.integrations.strands._messages import _message_text from neo4j_agent_memory.integrations.strands._retrieval import _retrieve_entries if TYPE_CHECKING: from types import TracebackType + from strands.types.content import Message as StrandsMessage + from neo4j_agent_memory import MemoryClient, MemorySettings from neo4j_agent_memory.nams.endpoints import TransportMode @@ -39,6 +48,9 @@ #: (mirrors ``strands.memory.memory_manager.DEFAULT_MAX_SEARCH_RESULTS``). _DEFAULT_MAX_SEARCH_RESULTS = 3 +#: NAMS caps bulk message writes; chunk to stay inside it on both backends. +_BULK_CHUNK = 100 + __all__ = ["Neo4jMemoryStore", "Neo4jMemoryStoreConfig"] @@ -294,6 +306,60 @@ async def _add_to_sink(self, content: str) -> dict[str, Any]: ) return {"kind": "message", "id": str(message.id)} + async def add_messages( + self, + messages: list[StrandsMessage], + context: AddMessagesContext | None = None, + ) -> dict[str, Any]: + """Ingest a batch of conversation turns into the sink conversation. + + The backend extracts them — server-side on NAMS, inline on bolt — so no + model call happens here. Extraction writes are at-least-once and + ``AddMessagesContext.sequence_numbers`` repeat on a retry, so a + ``(run_id, sequence_number)`` set skips turns already written by this + instance. The dedupe is in-process only: sequence numbers reset each run, + so there is nothing durable to key on. + """ + if not self.writable: + raise ValueError( + f"Neo4jMemoryStore '{self.name}': store is not writable. " + "Set writable=True to enable add_messages()." + ) + await self.initialize() + + sequence_numbers = (context.sequence_numbers if context else None) or [] + payload: list[dict[str, Any]] = [] + tokens: list[tuple[str, int] | None] = [] + skipped = 0 + + for index, message in enumerate(messages): + text = _message_text(message) + if not text.strip(): + skipped += 1 + continue + token: tuple[str, int] | None = None + if index < len(sequence_numbers): + token = (self._run_id, sequence_numbers[index]) + if token in self._written: + skipped += 1 + continue + payload.append({"role": message.get("role", "user"), "content": text}) + tokens.append(token) + + if not payload: + return {"written": 0, "skipped": skipped} + + sink = await self._resolve_sink() + for start in range(0, len(payload), _BULK_CHUNK): + await self._client.short_term.bulk_add_messages( + sink, + payload[start : start + _BULK_CHUNK], + extract_entities=True, + user_identifier=self.user_id, + ) + self._written.update(token for token in tokens if token is not None) + return {"written": len(payload), "skipped": skipped} + async def aclose(self) -> None: """Close the client only when the store constructed it.""" if self._owns_client: diff --git a/tests/unit/integrations/strands_fakes.py b/tests/unit/integrations/strands_fakes.py index 5a1bc288..a807f830 100644 --- a/tests/unit/integrations/strands_fakes.py +++ b/tests/unit/integrations/strands_fakes.py @@ -21,6 +21,7 @@ def __init__(self, nams_mode: bool) -> None: # key -> Conversation. Bolt: key == session_id. NAMS: key == str(uuid). self.conversations: dict[str, Conversation] = {} self.add_message_calls: list[dict[str, Any]] = [] + self.bulk_calls: list[dict[str, Any]] = [] self.deleted_message_ids: list[str] = [] self.fail_next_add = False self.list_conversations_calls: list[dict[str, Any]] = [] @@ -78,6 +79,21 @@ async def add_message(self, session_id: str, role: str, content: str, **kwargs: self.conversations[session_id].messages.append(msg) return msg + async def bulk_add_messages( + self, session_id: str, messages: list[dict[str, Any]], **kwargs: Any + ) -> list[Message]: + recorded_kwargs = {} if self._nams_mode else kwargs + self.bulk_calls.append( + {"session_id": session_id, "messages": messages, "kwargs": recorded_kwargs} + ) + if session_id not in self.conversations: + if self._nams_mode: + raise NamMemoryError(f"NAMS: unknown conversation {session_id}") + await self.create_conversation(session_id=session_id) + stored = [Message(role=MessageRole(m["role"]), content=m["content"]) for m in messages] + self.conversations[session_id].messages.extend(stored) + return stored + async def delete_message(self, message_id: Any, **kwargs: Any) -> bool: if self._nams_mode: from neo4j_agent_memory.core.exceptions import NotSupportedError diff --git a/tests/unit/integrations/test_strands_memory_store.py b/tests/unit/integrations/test_strands_memory_store.py index eca41eb3..90cd1cf5 100644 --- a/tests/unit/integrations/test_strands_memory_store.py +++ b/tests/unit/integrations/test_strands_memory_store.py @@ -55,25 +55,6 @@ def test_settings_construction_owns_the_client(self) -> None: assert store._owns_client is True assert isinstance(store._client, MemoryClient) - def test_add_messages_is_not_declared_yet(self) -> None: - """Scope guard: `add` landed in task 7, `add_messages` lands in task 8. - - `_has_method` compares `getattr(type(store), name)` against the Protocol's - own stub by identity, so an undefined method reads as absent. `add` is now - real, so `_has_write_sink` is already True; the both-sinks assertion lives - in task 8. - """ - from strands.memory.types import _has_method, _has_write_sink - - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - - store = Neo4jMemoryStore(name="graph", client=FakeMemoryClient()) - - assert _has_method(store, "initialize") is True - assert _has_method(store, "add") is True - assert _has_method(store, "add_messages") is False - assert _has_write_sink(store) is True - class TestLifecycle: @pytest.mark.asyncio @@ -511,3 +492,153 @@ async def test_rejects_empty_content(self) -> None: with pytest.raises(ValueError, match="empty"): await store.add(" ") + + +class TestAddMessages: + @pytest.mark.asyncio + async def test_writes_the_batch_to_the_sink_with_extraction(self) -> None: + from strands.memory import AddMessagesContext + + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient() + store = Neo4jMemoryStore(name="graph", client=client) + await store.initialize() + + result = await store.add_messages( + [ + {"role": "user", "content": [{"text": "I prefer dark mode"}]}, + {"role": "assistant", "content": [{"text": "Noted"}]}, + ], + AddMessagesContext(sequence_numbers=[0, 1]), + ) + + call = client.short_term.bulk_calls[-1] + assert call["kwargs"]["extract_entities"] is True + assert [m["content"] for m in call["messages"]] == ["I prefer dark mode", "Noted"] + assert [m["role"] for m in call["messages"]] == ["user", "assistant"] + assert result == {"written": 2, "skipped": 0} + + @pytest.mark.asyncio + async def test_a_retried_batch_is_not_written_twice(self) -> None: + """Extraction writes are at-least-once; the same sequence numbers repeat.""" + from strands.memory import AddMessagesContext + + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient() + store = Neo4jMemoryStore(name="graph", client=client) + await store.initialize() + batch = [{"role": "user", "content": [{"text": "ok"}]}] + + first = await store.add_messages(batch, AddMessagesContext(sequence_numbers=[0])) + second = await store.add_messages(batch, AddMessagesContext(sequence_numbers=[0])) + + assert first == {"written": 1, "skipped": 0} + assert second == {"written": 0, "skipped": 1} + assert len(client.short_term.bulk_calls) == 1 + + @pytest.mark.asyncio + async def test_identical_text_with_distinct_sequence_numbers_is_kept(self) -> None: + """Dedupe keys on sequence number, not content — two 'ok's are two messages.""" + from strands.memory import AddMessagesContext + + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient() + store = Neo4jMemoryStore(name="graph", client=client) + await store.initialize() + + result = await store.add_messages( + [ + {"role": "user", "content": [{"text": "ok"}]}, + {"role": "user", "content": [{"text": "ok"}]}, + ], + AddMessagesContext(sequence_numbers=[3, 4]), + ) + + assert result == {"written": 2, "skipped": 0} + + @pytest.mark.asyncio + async def test_without_sequence_numbers_everything_is_written(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient() + store = Neo4jMemoryStore(name="graph", client=client) + await store.initialize() + batch = [{"role": "user", "content": [{"text": "ok"}]}] + + assert await store.add_messages(batch, None) == {"written": 1, "skipped": 0} + assert await store.add_messages(batch, None) == {"written": 1, "skipped": 0} + + @pytest.mark.asyncio + async def test_messages_with_no_text_blocks_are_dropped(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient() + store = Neo4jMemoryStore(name="graph", client=client) + await store.initialize() + + result = await store.add_messages( + [{"role": "assistant", "content": [{"toolUse": {"name": "x", "input": {}}}]}], + None, + ) + + assert result == {"written": 0, "skipped": 1} + assert client.short_term.bulk_calls == [] + + @pytest.mark.asyncio + async def test_batches_larger_than_100_are_chunked(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + client = FakeMemoryClient() + store = Neo4jMemoryStore(name="graph", client=client) + await store.initialize() + + messages = [{"role": "user", "content": [{"text": f"m{i}"}]} for i in range(250)] + result = await store.add_messages(messages, None) + + assert result == {"written": 250, "skipped": 0} + assert [len(c["messages"]) for c in client.short_term.bulk_calls] == [100, 100, 50] + + @pytest.mark.asyncio + async def test_rejects_writes_when_not_writable(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + store = Neo4jMemoryStore(name="graph", client=FakeMemoryClient(), writable=False) + await store.initialize() + + with pytest.raises(ValueError, match="not writable"): + await store.add_messages([{"role": "user", "content": [{"text": "x"}]}], None) + + +class TestWriteSinks: + def test_declares_both_write_sinks(self) -> None: + """Both sinks on one class: server-side extraction, `add` still available.""" + from strands.memory.types import _has_method, _has_write_sink + + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + store = Neo4jMemoryStore(name="graph", client=FakeMemoryClient()) + + assert _has_method(store, "add") is True + assert _has_method(store, "add_messages") is True + assert _has_write_sink(store) is True + + def test_server_side_extraction_is_the_resolved_default(self) -> None: + """`add_messages` present -> no ModelExtractor, so no extra model call. + + Non-vacuous only here: with `add` alone (task 7) this would resolve to a + ModelExtractor, and with neither sink it resolves to None trivially. + """ + from strands.memory.extraction.resolve_extraction_config import ( + _resolve_extraction_config, + ) + + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + + store = Neo4jMemoryStore(name="graph", client=FakeMemoryClient(), extraction=True) + resolved = _resolve_extraction_config(store.extraction, store) + + assert resolved is not None + assert resolved.extractor is None From 9d62da7741905b835b614beaac8bd9a99d954bf1 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Wed, 19 Aug 2026 21:16:12 +0200 Subject: [PATCH 18/39] docs(changelog): record Neo4jMemoryStore and the strands floor bump --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37524762..64e8bdcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Strands MemoryStore** (`Neo4jMemoryStore`) — cross-session recall for Strands + agents via `MemoryManager(stores=[...])`: long-term search, plus writes that feed + server-side extraction. Entities only on NAMS. Needs `strands-agents>=1.44.0`. - **Strands SessionManager** (`Neo4jSessionManager`) — automatic conversation persistence/restore for AWS Strands agents via `Agent(session_manager=...)`, backed by any `MemoryClient` (bolt or NAMS). Includes opt-in long-term @@ -52,6 +55,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- `strands` extra requires `strands-agents>=1.44.0` (was `>=0.1.0`). +- `ShortTermProtocol.bulk_add_messages` accepts `**kwargs`, as both backends already did. + - **`MemoryClient` is now generic over its backend memory types** (`MemoryClient[ST, LT, RT]`, PEP 696 defaults). `client.short_term` / `.long_term` / `.reasoning` return the base `ShortTermProtocol` / From 40b84598a2656c31bb05a302bc8969e10eac2756 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 20 Aug 2026 10:01:00 +0200 Subject: [PATCH 19/39] fix(strands): tenant-scope the bulk message write path Neo4jMemoryStore.add_messages passes user_identifier to bulk_add_messages, but ShortTermMemory.add_messages_batch had no such parameter and no **kwargs catch-all, so bolt raised TypeError on every call. Under multi_tenant=True the bulk path also silently wrote unscoped, unlinked conversations with no enforcement error. Give add_messages_batch a user_identifier parameter that enforces multi_tenant (mirroring add_message) and links the conversation to its :User node. Widen ShortTermProtocol.bulk_add_messages to declare the same explicit keyword-only params instead of **kwargs. Tighten FakeShortTerm.bulk_add_messages to the same explicit signature so it can no longer absorb a keyword the real backend would reject, and add a regression test that binds the store's forwarded kwargs against the real add_messages_batch signature. Add integration coverage for the bulk-path tenant link and guardrail. --- CHANGELOG.md | 11 +++- src/neo4j_agent_memory/core/protocols.py | 15 ++++- src/neo4j_agent_memory/memory/short_term.py | 13 ++++- .../integration/test_multi_tenant_scoping.py | 57 +++++++++++++++++++ tests/unit/integrations/strands_fakes.py | 20 ++++++- 5 files changed, 106 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64e8bdcd..4c364c2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,11 +53,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 they raised `AttributeError`). This lets `client.short_term` / `client.long_term` type-check without per-call-site `attr-defined` suppressions. +### Fixed + +- **`add_messages_batch` now accepts `user_identifier`**, enforcing `multi_tenant` + and linking the conversation to its `:User`; previously the bulk path silently + wrote unscoped, unlinked conversations. + ### Changed - `strands` extra requires `strands-agents>=1.44.0` (was `>=0.1.0`). -- `ShortTermProtocol.bulk_add_messages` accepts `**kwargs`, as both backends already did. - +- `ShortTermProtocol.bulk_add_messages` takes explicit keyword-only params + (`generate_embeddings`, `extract_entities`, `extract_relations`, `user_identifier`) + instead of `**kwargs`. - **`MemoryClient` is now generic over its backend memory types** (`MemoryClient[ST, LT, RT]`, PEP 696 defaults). `client.short_term` / `.long_term` / `.reasoning` return the base `ShortTermProtocol` / diff --git a/src/neo4j_agent_memory/core/protocols.py b/src/neo4j_agent_memory/core/protocols.py index 8de46121..418783cd 100644 --- a/src/neo4j_agent_memory/core/protocols.py +++ b/src/neo4j_agent_memory/core/protocols.py @@ -187,12 +187,21 @@ async def bulk_add_messages( self, session_id: str, messages: list[dict[str, Any]], - **kwargs: Any, + *, + generate_embeddings: bool = True, + extract_entities: bool = False, + extract_relations: bool = True, + user_identifier: str | None = None, ) -> list[Message]: """Bulk-insert messages for a session in one round-trip, preserving order. - ``**kwargs`` forwards backend-specific knobs (``extract_entities``, - ``user_identifier``, …) — both implementations already accept them. + The portable subset: honoured on bolt (including tenant scoping via + ``user_identifier``, enforced when ``multi_tenant=True``); ignored on + NAMS (extraction is server-side there, and tenancy comes from the API + key/workspace, not a per-call argument). Bolt-only tuning + (``batch_size``) and the progress callbacks are intentionally off + this protocol — reach for ``BoltMemoryClient.add_messages_batch`` + when you need those. """ ... diff --git a/src/neo4j_agent_memory/memory/short_term.py b/src/neo4j_agent_memory/memory/short_term.py index cbeb3516..941deb5b 100644 --- a/src/neo4j_agent_memory/memory/short_term.py +++ b/src/neo4j_agent_memory/memory/short_term.py @@ -373,6 +373,7 @@ async def add_messages_batch( extract_relations: bool = True, on_progress: Callable[[int, int], None] | None = None, on_batch_complete: Callable[[int, list[Message]], None] | None = None, + user_identifier: str | None = None, ) -> list[Message]: """ Bulk load messages with transaction batching for better performance. @@ -398,6 +399,9 @@ async def add_messages_batch( (only applies when extract_entities=True) on_progress: Callback for progress updates (completed_count, total_count) on_batch_complete: Callback after each batch completes (batch_num, batch_messages) + user_identifier: When provided, scopes the conversation to a + :User node via ``(:User)-[:HAS_CONVERSATION]->(:Conversation)``. + Required when ``MemorySettings.multi_tenant=True``. Returns: List of created Message objects @@ -405,8 +409,11 @@ async def add_messages_batch( if not messages: return [] - # Ensure conversation exists - conv_id = await self._ensure_conversation(session_id, None) + # Multi-tenant guardrail (same as add_message). + self._enforce_multi_tenant(user_identifier) + + # Ensure conversation exists, tenant-linked when user_identifier is given. + conv_id = await self._ensure_conversation(session_id, None, user_identifier=user_identifier) total = len(messages) all_created: list[Message] = [] @@ -567,7 +574,7 @@ async def bulk_add_messages( Thin ``ShortTermProtocol`` alias over :meth:`add_messages_batch`; extra keyword arguments (``batch_size``, ``generate_embeddings``, - ``extract_entities``, …) are forwarded. + ``extract_entities``, ``user_identifier``, …) are forwarded. """ return await self.add_messages_batch(session_id, messages, **kwargs) diff --git a/tests/integration/test_multi_tenant_scoping.py b/tests/integration/test_multi_tenant_scoping.py index b4f0fa63..c93da8b6 100644 --- a/tests/integration/test_multi_tenant_scoping.py +++ b/tests/integration/test_multi_tenant_scoping.py @@ -7,6 +7,7 @@ * ``add_message(user_identifier=...)`` writes ``(:User)-[:HAS_CONVERSATION]->(:Conversation)`` and denormalizes ``user_identifier`` onto the Conversation node. +* ``add_messages_batch(user_identifier=...)`` does the same on the bulk path. * ``start_trace(user_identifier=...)`` writes ``(:User)-[:HAS_TRACE]->(:ReasoningTrace)``. """ @@ -109,6 +110,54 @@ async def test_two_users_same_session_id_get_separate_conversations(self, clean_ assert sorted(rows[0]["users"]) == ["liam@omg.com", "sara@omg.com"] +@pytest.mark.integration +@pytest.mark.asyncio +class TestUserScopedBulkMessages: + """Bulk-path coverage for the ``add_messages_batch(user_identifier=...)`` fix. + + Previously ``add_messages_batch`` had no ``user_identifier`` parameter, so + a multi-tenant deployment writing through the bulk path got an unscoped, + unlinked conversation and no enforcement error. + """ + + async def test_add_messages_batch_writes_has_conversation_edge( + self, clean_memory_client, session_id + ): + client = clean_memory_client + await client.users.upsert_user(identifier="sara@omg.com") + + await client.short_term.add_messages_batch( + session_id, + [{"role": "user", "content": "Find healthcare team"}], + user_identifier="sara@omg.com", + ) + + rows = await client.graph.execute_read( + """ + MATCH (u:User {identifier: 'sara@omg.com'})-[:HAS_CONVERSATION]->(c:Conversation) + RETURN c.session_id AS session_id, c.user_identifier AS user_identifier + """, + ) + assert len(rows) == 1 + assert rows[0]["session_id"] == session_id + assert rows[0]["user_identifier"] == "sara@omg.com" + + async def test_conversation_discoverable_via_list_conversations( + self, clean_memory_client, session_id + ): + client = clean_memory_client + await client.users.upsert_user(identifier="sara@omg.com") + + await client.short_term.add_messages_batch( + session_id, + [{"role": "user", "content": "Find healthcare team"}], + user_identifier="sara@omg.com", + ) + + conversations = await client.short_term.list_conversations(user_identifier="sara@omg.com") + assert [c.session_id for c in conversations] == [session_id] + + @pytest.mark.integration @pytest.mark.asyncio class TestUserScopedReasoningTrace: @@ -148,6 +197,14 @@ async def test_start_trace_raises_without_user_identifier( with pytest.raises(ValueError, match="user_identifier"): await multi_tenant_client.reasoning.start_trace(session_id, "Some task") + async def test_add_messages_batch_raises_without_user_identifier( + self, multi_tenant_client, session_id + ): + with pytest.raises(ValueError, match="user_identifier"): + await multi_tenant_client.short_term.add_messages_batch( + session_id, [{"role": "user", "content": "Hello"}] + ) + async def test_add_preference_raises_without_user_identifier(self, multi_tenant_client): with pytest.raises(ValueError, match="user_identifier"): await multi_tenant_client.long_term.add_preference("food", "Italian") diff --git a/tests/unit/integrations/strands_fakes.py b/tests/unit/integrations/strands_fakes.py index a807f830..67a75bd3 100644 --- a/tests/unit/integrations/strands_fakes.py +++ b/tests/unit/integrations/strands_fakes.py @@ -80,8 +80,24 @@ async def add_message(self, session_id: str, role: str, content: str, **kwargs: return msg async def bulk_add_messages( - self, session_id: str, messages: list[dict[str, Any]], **kwargs: Any + self, + session_id: str, + messages: list[dict[str, Any]], + *, + generate_embeddings: bool = True, + extract_entities: bool = False, + extract_relations: bool = True, + user_identifier: str | None = None, ) -> list[Message]: + # Explicit parameters mirroring ShortTermProtocol.bulk_add_messages + # (no **kwargs catch-all) so this fake can't absorb a keyword the + # real bolt backend would reject. + kwargs = { + "generate_embeddings": generate_embeddings, + "extract_entities": extract_entities, + "extract_relations": extract_relations, + "user_identifier": user_identifier, + } recorded_kwargs = {} if self._nams_mode else kwargs self.bulk_calls.append( {"session_id": session_id, "messages": messages, "kwargs": recorded_kwargs} @@ -89,7 +105,7 @@ async def bulk_add_messages( if session_id not in self.conversations: if self._nams_mode: raise NamMemoryError(f"NAMS: unknown conversation {session_id}") - await self.create_conversation(session_id=session_id) + await self.create_conversation(session_id=session_id, user_identifier=user_identifier) stored = [Message(role=MessageRole(m["role"]), content=m["content"]) for m in messages] self.conversations[session_id].messages.extend(stored) return stored From 484dd927828423c319cb973c09f8d76da0e01df3 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 20 Aug 2026 10:02:28 +0200 Subject: [PATCH 20/39] refactor(strands): Neo4jMemoryStoreConfig as a checked dataclass Replace the **store_config: Unpack[Neo4jMemoryStoreConfig] pattern with a plain @dataclass: config.user_id is now a checked attribute instead of a string-keyed config.get("user_id") read. Validation (non-empty name; not both client and settings) moves into __post_init__ so it fails at the line the caller wrote; the "neither given" case is checked in Neo4jMemoryStore.__init__ instead, since a config en route to for_nams legitimately omits both until for_nams completes it via dataclasses.replace (which never mutates the caller's config). Also: read is_nams/is_connected directly instead of through getattr fallbacks now that both are real MemoryClient properties; return a local variable from _resolve_sink instead of the Optional attribute to clear the type-checker warning; unpack the fact-kind metadata directly instead of building a tuple just to destructure it; construct real Neo4jConfig/SecretStr instances in the settings-related store tests instead of a raw dict; cast the fakes passed into LongTermProtocol-typed _retrieve_entries parameters; and reorder/tidy the CHANGELOG Changed section. --- .../2026-08-19-strands-memory-store-design.md | 43 ++- .../integrations/strands/memory_store.py | 173 ++++++----- .../integrations/test_strands_memory_store.py | 275 ++++++++++-------- .../test_strands_retrieval_entries.py | 38 ++- 4 files changed, 329 insertions(+), 200 deletions(-) diff --git a/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md b/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md index 6ff66a77..e9ab698b 100644 --- a/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md +++ b/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md @@ -73,22 +73,55 @@ gains a `MemoryEntry`-shaped sibling to `_retrieve_context`; `_messages.py` is r unchanged. Shape follows the vended stores (`strands.vended_memory_stores.bedrock_knowledge_base`, -`test_memory_store`): subclass the Protocol, config as a `TypedDict`. +`test_memory_store`) for subclassing the Protocol, but config is a plain +`@dataclass`, not a `TypedDict`/`Unpack`: `MemoryStoreConfig` is never received +by any Strands API (`MemoryManager.__init__` takes `stores: list[MemoryStore]`, +i.e. instances) — the real contract, per `strands.memory.types`, is "a store +exposes the `MemoryStoreConfig` fields as attributes." Inheriting the +`TypedDict` bought nothing at runtime or at any boundary, while forcing +unchecked `config.get(...)` reads internally (`total=False` rules out +`config["..."]`). A dataclass gives `mypy --strict`-checked attribute access +throughout, and `dataclasses.replace(config, name="team")` reuses one config +across several stores (personal / team / org). ```python +@dataclass +class Neo4jMemoryStoreConfig: + name: str + client: MemoryClient | None = None + settings: MemorySettings | None = None + description: str | None = None + max_search_results: int | None = None + writable: bool = True + extraction: ExtractionConfig | bool = False + conversation_id: str | None = None + user_id: str | None = None + include_entities: bool = True + include_preferences: bool = True + include_facts: bool = True + min_score: float = 0.2 + graph_tools: bool = True + + def __post_init__(self) -> None: ... # non-empty name; not both client and settings + + class Neo4jMemoryStore(MemoryStore): - def __init__(self, **store_config: Unpack[Neo4jMemoryStoreConfig]) -> None: ... + def __init__(self, config: Neo4jMemoryStoreConfig) -> None: ... # assigns the five protocol fields onto self @classmethod - def for_nams(cls, **store_config: Unpack[Neo4jMemoryStoreConfig]) -> Neo4jMemoryStore: ... + def for_nams(cls, config: Neo4jMemoryStoreConfig, *, endpoint: str | None = None, api_key: str | None = None, transport_mode: TransportMode = "auto") -> Neo4jMemoryStore: ... ``` `for_nams` mirrors `Neo4jSessionManager.for_nams`, reading `MEMORY_API_KEY` / -`MEMORY_ENDPOINT` from the environment. +`MEMORY_ENDPOINT` from the environment; it does not mutate the caller's +`config` — `dataclasses.replace(config, settings=build_nams_settings(...))` +returns a copy with `settings` injected and constructs from that. ### Config -`Neo4jMemoryStoreConfig` extends `MemoryStoreConfig` with: +`Neo4jMemoryStoreConfig`'s fields, beyond `name`/`description`/ +`max_search_results`/`writable`/`extraction` (the ones `MemoryStore`'s +protocol requires as store attributes): | Field | Default | Purpose | |---|---|---| diff --git a/src/neo4j_agent_memory/integrations/strands/memory_store.py b/src/neo4j_agent_memory/integrations/strands/memory_store.py index c48395e2..5a9f746f 100644 --- a/src/neo4j_agent_memory/integrations/strands/memory_store.py +++ b/src/neo4j_agent_memory/integrations/strands/memory_store.py @@ -9,16 +9,14 @@ import logging import uuid +from dataclasses import dataclass, replace from typing import TYPE_CHECKING, Any -from typing_extensions import Unpack - try: from strands.memory import ( AddMessagesContext, MemoryEntry, MemoryStore, - MemoryStoreConfig, SearchOptions, ) except ImportError as import_error: # pragma: no cover - exercised via package __init__ @@ -34,6 +32,7 @@ if TYPE_CHECKING: from types import TracebackType + from strands.memory import ExtractionConfig from strands.types.content import Message as StrandsMessage from neo4j_agent_memory import MemoryClient, MemorySettings @@ -54,85 +53,118 @@ __all__ = ["Neo4jMemoryStore", "Neo4jMemoryStoreConfig"] -class Neo4jMemoryStoreConfig(MemoryStoreConfig, total=False): +@dataclass +class Neo4jMemoryStoreConfig: """Configuration for :class:`Neo4jMemoryStore`. - Extends Strands' ``MemoryStoreConfig`` (``name``, ``description``, - ``max_search_results``, ``writable``, ``extraction``) with the Neo4j - connection, scoping, and search knobs. + A plain dataclass, not a ``TypedDict``: every field is a checked + attribute (``config.user_id``, never ``config.get("user_id")``), so a + typo is a ``mypy --strict`` error rather than a silently-``None`` read. + Reuse one config across several stores — personal / team / org — with + ``dataclasses.replace(config, name="team")``. + + ``name``, ``description``, ``max_search_results``, ``writable`` and + ``extraction`` are the fields ``MemoryStore``'s protocol requires the + store to expose as instance attributes; the rest are Neo4j connection, + scoping, and search knobs. """ - client: MemoryClient - settings: MemorySettings - conversation_id: str - user_id: str - include_entities: bool - include_preferences: bool - include_facts: bool - min_score: float - graph_tools: bool + name: str + client: MemoryClient | None = None + settings: MemorySettings | None = None + description: str | None = None + max_search_results: int | None = None + writable: bool = True + extraction: ExtractionConfig | bool = False + conversation_id: str | None = None + user_id: str | None = None + include_entities: bool = True + include_preferences: bool = True + include_facts: bool = True + min_score: float = 0.2 + graph_tools: bool = True + + def __post_init__(self) -> None: + if not self.name: + raise ValueError("Neo4jMemoryStore: 'name' is required and must be non-empty") + # Only the unambiguous mistake (both given) is checkable here. A + # config en route to `for_nams` legitimately has neither set yet — + # `for_nams` completes it with `settings` before construction — so + # "neither given" is checked in `Neo4jMemoryStore.__init__` instead, + # once we know no such completion step is coming. + if self.client is not None and self.settings is not None: + raise ValueError( + "Neo4jMemoryStore: pass exactly one of 'client' (borrowed, left open) " + "or 'settings' (a client is constructed and owned by the store)" + ) class Neo4jMemoryStore(MemoryStore): - """Long-term memory recall and ingestion over a Neo4j context graph.""" - - def __init__(self, **store_config: Unpack[Neo4jMemoryStoreConfig]) -> None: - name = store_config.get("name") - if not name: - raise ValueError("Neo4jMemoryStore: 'name' is required and must be non-empty") + """Long-term memory recall and ingestion over a Neo4j context graph. + + Example: + store = Neo4jMemoryStore( + Neo4jMemoryStoreConfig( + name="graph", + client=client, # or settings=MemorySettings(...) + user_id="alice", + ) + ) + """ - client = store_config.get("client") - settings = store_config.get("settings") - if (client is None) == (settings is None): + def __init__(self, config: Neo4jMemoryStoreConfig) -> None: + if config.client is None and config.settings is None: raise ValueError( "Neo4jMemoryStore: pass exactly one of 'client' (borrowed, left open) " "or 'settings' (a client is constructed and owned by the store)" ) - self.name = name - self.description = store_config.get( - "description", f"Neo4j context graph '{name}': entities, preferences and facts." + # The five attributes MemoryStore's protocol requires the store to expose. + self.name = config.name + self.description = config.description or ( + f"Neo4j context graph '{config.name}': entities, preferences and facts." ) - self.max_search_results = store_config.get("max_search_results") - self.writable = store_config.get("writable", True) - self.extraction = store_config.get("extraction", False) - - self.user_id = store_config.get("user_id") - self.graph_tools = store_config.get("graph_tools", True) - self._include_entities = store_config.get("include_entities", True) - self._include_preferences = store_config.get("include_preferences", True) - self._include_facts = store_config.get("include_facts", True) - self._min_score = store_config.get("min_score", 0.2) - - self._conversation_id = store_config.get("conversation_id") - self._sink_key: str | None = self._conversation_id - self._owns_client = client is None + self.max_search_results = config.max_search_results + self.writable = config.writable + self.extraction = config.extraction + + self.user_id = config.user_id + self.graph_tools = config.graph_tools + self._include_entities = config.include_entities + self._include_preferences = config.include_preferences + self._include_facts = config.include_facts + self._min_score = config.min_score + + self._conversation_id = config.conversation_id + self._sink_key: str | None = config.conversation_id + self._owns_client = config.client is None self._run_id = uuid.uuid4().hex self._written: set[tuple[str, int]] = set() self._initialized = False self._warned_unsupported_kinds: set[str] = set() - if client is not None: - self._client: MemoryClient = client + if config.client is not None: + self._client: MemoryClient = config.client else: from neo4j_agent_memory import MemoryClient as _MemoryClient - assert settings is not None - self._client = _MemoryClient(settings) + assert config.settings is not None + self._client = _MemoryClient(config.settings) @classmethod def for_nams( cls, + config: Neo4jMemoryStoreConfig, *, endpoint: str | None = None, api_key: str | None = None, transport_mode: TransportMode = "auto", - **store_config: Unpack[Neo4jMemoryStoreConfig], ) -> Neo4jMemoryStore: """Construct a store against hosted NAMS. Reads ``MEMORY_API_KEY`` (and optionally ``MEMORY_ENDPOINT``) from the - environment when not passed explicitly. + environment when not passed explicitly. ``config`` is not mutated: + ``dataclasses.replace`` returns a copy with ``settings`` injected. """ from neo4j_agent_memory.integrations.strands.config import ( build_nams_settings, @@ -140,12 +172,12 @@ def for_nams( ) endpoint, api_key = resolve_nams_connection(endpoint, api_key) - store_config["settings"] = build_nams_settings(endpoint, api_key, transport_mode) - return cls(**store_config) + merged = replace(config, settings=build_nams_settings(endpoint, api_key, transport_mode)) + return cls(merged) @property def is_nams(self) -> bool: - return bool(getattr(self._client, "is_nams", False)) + return self._client.is_nams @property def _sink_name(self) -> str: @@ -157,21 +189,20 @@ async def _resolve_sink(self) -> str: An explicit ``conversation_id`` is used verbatim. Otherwise: bolt keys conversations by ``session_id`` and ``add_message``/``add_messages_batch`` - both auto-create the sink via ``_ensure_conversation`` on first write, so - the deterministic sink name *is* the whole contract — no backend call is - made here, and none is needed. Bolt's ``CREATE_CONVERSATION`` query also - has no metadata property, so tagging one is not possible even if we - called ``create_conversation`` eagerly. NAMS mints its own conversation - ids, so metadata is the only portable handle there — list and match - ``_STORE_KEY`` metadata, else create. Same split as - ``Neo4jSessionManager._aresolve_conversation``. + both auto-create the sink (and tenant-link it via ``user_identifier``) + via ``_ensure_conversation`` on first write, so the deterministic sink + name *is* the whole contract — no backend call is made here, and none + is needed. NAMS mints its own conversation ids, so metadata is the + only portable handle there — list and match ``_STORE_KEY`` metadata, + else create. Same split as ``Neo4jSessionManager._aresolve_conversation``. """ if self._sink_key is not None: return self._sink_key if not self.is_nams: - self._sink_key = self._sink_name - return self._sink_key + sink_key = self._sink_name + self._sink_key = sink_key + return sink_key short_term = self._client.short_term conversations = await short_term.list_conversations( @@ -179,22 +210,24 @@ async def _resolve_sink(self) -> str: ) for conversation in conversations: if (conversation.metadata or {}).get(_STORE_KEY) == self._sink_name: - self._sink_key = str(conversation.id) - return self._sink_key + sink_key = str(conversation.id) + self._sink_key = sink_key + return sink_key created = await short_term.create_conversation( session_id=self._sink_name, metadata={_STORE_KEY: self._sink_name, "session_type": "MEMORY_STORE"}, user_identifier=self.user_id, ) - self._sink_key = str(created.id) - return self._sink_key + sink_key = str(created.id) + self._sink_key = sink_key + return sink_key async def initialize(self) -> None: """Connect the client if not already connected. Idempotent.""" if self._initialized: return - if not getattr(self._client, "is_connected", False): + if not self._client.is_connected: await self._client.connect() self._initialized = True @@ -273,11 +306,9 @@ async def _add_typed(self, kind: str, content: str, meta: dict[str, Any]) -> dic preference = await long_term.add_preference(meta.get("category", "memory"), content) return {"kind": "preference", "id": str(preference.id)} if kind == "fact": - subject, predicate, obj = ( - meta.get("subject"), - meta.get("predicate"), - meta.get("object"), - ) + subject = meta.get("subject") + predicate = meta.get("predicate") + obj = meta.get("object") if not (subject and predicate and obj): raise ValueError( f"Neo4jMemoryStore '{self.name}': kind='fact' requires " diff --git a/tests/unit/integrations/test_strands_memory_store.py b/tests/unit/integrations/test_strands_memory_store.py index 90cd1cf5..de1c743b 100644 --- a/tests/unit/integrations/test_strands_memory_store.py +++ b/tests/unit/integrations/test_strands_memory_store.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + import pytest pytest.importorskip("strands", reason="strands-agents not installed") @@ -9,30 +11,44 @@ from tests.unit.integrations.strands_fakes import FakeMemoryClient +def _store(**kw: Any) -> Any: + """Build a Neo4jMemoryStore from loose kwargs via its real config dataclass. + + Keeps the individual tests readable while still exercising the actual + ``Neo4jMemoryStoreConfig`` field names — ``Neo4jMemoryStoreConfig(**kw)`` + is the real dataclass constructor, so a typo in a field name here is a + ``TypeError``, not a silently-ignored key. + """ + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore, Neo4jMemoryStoreConfig + + return Neo4jMemoryStore(Neo4jMemoryStoreConfig(**kw)) + + class TestConstruction: def test_requires_a_name(self) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + """``name`` must be non-empty; checked eagerly in + ``Neo4jMemoryStoreConfig.__post_init__``, before the store ever sees it.""" with pytest.raises(ValueError, match="name"): - Neo4jMemoryStore(client=FakeMemoryClient()) # type: ignore[call-arg] + _store(name="", client=FakeMemoryClient()) def test_requires_exactly_one_of_client_or_settings(self) -> None: + from pydantic import SecretStr + from neo4j_agent_memory import MemorySettings - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + from neo4j_agent_memory.config.settings import Neo4jConfig with pytest.raises(ValueError, match="exactly one"): - Neo4jMemoryStore(name="s") + _store(name="s") with pytest.raises(ValueError, match="exactly one"): - Neo4jMemoryStore( + _store( name="s", client=FakeMemoryClient(), - settings=MemorySettings(neo4j={"password": "p"}), + settings=MemorySettings(neo4j=Neo4jConfig(password=SecretStr("p"))), ) def test_protocol_attribute_defaults(self) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - - store = Neo4jMemoryStore(name="graph", client=FakeMemoryClient()) + store = _store(name="graph", client=FakeMemoryClient()) assert store.name == "graph" assert store.writable is True @@ -41,16 +57,44 @@ def test_protocol_attribute_defaults(self) -> None: assert store.description is not None and "graph" in store.description.lower() assert store.graph_tools is True + def test_protocol_fields_are_all_assigned_onto_the_store(self) -> None: + """``MemoryStore``'s protocol requires name/description/max_search_results/ + writable/extraction as instance attributes. A future rename of one of + these on ``Neo4jMemoryStoreConfig`` that forgot the matching + ``self.x = config.x`` line in ``__init__`` would silently drop a + protocol attribute — this pins all five down against the config.""" + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore, Neo4jMemoryStoreConfig + + config = Neo4jMemoryStoreConfig( + name="graph", + client=FakeMemoryClient(), + description="a custom description", + max_search_results=9, + writable=False, + extraction=True, + ) + store = Neo4jMemoryStore(config) + + assert store.name == config.name + assert store.description == config.description + assert store.max_search_results == config.max_search_results + assert store.writable == config.writable + assert store.extraction == config.extraction + def test_settings_construction_owns_the_client(self) -> None: """Settings-constructed stores build and own a real MemoryClient. MemoryClient.__init__ is lazy (no connection, no embedder until .connect()), so this is cheap and does not touch the network. """ + from pydantic import SecretStr + from neo4j_agent_memory import MemoryClient, MemorySettings - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + from neo4j_agent_memory.config.settings import Neo4jConfig - store = Neo4jMemoryStore(name="graph", settings=MemorySettings(neo4j={"password": "p"})) + store = _store( + name="graph", settings=MemorySettings(neo4j=Neo4jConfig(password=SecretStr("p"))) + ) assert store._owns_client is True assert isinstance(store._client, MemoryClient) @@ -59,10 +103,8 @@ def test_settings_construction_owns_the_client(self) -> None: class TestLifecycle: @pytest.mark.asyncio async def test_initialize_connects_an_owned_client_only(self) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - client = FakeMemoryClient() - store = Neo4jMemoryStore(name="graph", client=client) + store = _store(name="graph", client=client) await store.initialize() assert client.connect_calls == 1 @@ -71,10 +113,8 @@ async def test_initialize_connects_an_owned_client_only(self) -> None: @pytest.mark.asyncio async def test_initialize_is_idempotent(self) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - client = FakeMemoryClient() - store = Neo4jMemoryStore(name="graph", client=client) + store = _store(name="graph", client=client) await store.initialize() await store.initialize() @@ -85,10 +125,14 @@ async def test_context_manager_closes_an_owned_client(self) -> None: """Ownership must come from the constructor (settings=), not a monkey-patched flag.""" from unittest.mock import AsyncMock + from pydantic import SecretStr + from neo4j_agent_memory import MemorySettings - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + from neo4j_agent_memory.config.settings import Neo4jConfig - store = Neo4jMemoryStore(name="graph", settings=MemorySettings(neo4j={"password": "p"})) + store = _store( + name="graph", settings=MemorySettings(neo4j=Neo4jConfig(password=SecretStr("p"))) + ) store._client.connect = AsyncMock() # type: ignore[method-assign] store._client.close = AsyncMock() # type: ignore[method-assign] @@ -100,33 +144,53 @@ async def test_context_manager_closes_an_owned_client(self) -> None: class TestForNams: def test_builds_nams_settings_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + from neo4j_agent_memory.integrations.strands import ( + Neo4jMemoryStore, + Neo4jMemoryStoreConfig, + ) monkeypatch.setenv("MEMORY_API_KEY", "test-key") - store = Neo4jMemoryStore.for_nams(name="graph") + store = Neo4jMemoryStore.for_nams(Neo4jMemoryStoreConfig(name="graph")) settings = store._client._settings assert settings.backend == "nams" assert settings.nams.validate_on_connect is False def test_raises_without_api_key(self, monkeypatch: pytest.MonkeyPatch) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore + from neo4j_agent_memory.integrations.strands import ( + Neo4jMemoryStore, + Neo4jMemoryStoreConfig, + ) monkeypatch.delenv("MEMORY_API_KEY", raising=False) with pytest.raises(ValueError, match="api_key is required"): - Neo4jMemoryStore.for_nams(name="graph") + Neo4jMemoryStore.for_nams(Neo4jMemoryStoreConfig(name="graph")) + + def test_does_not_mutate_the_callers_config(self, monkeypatch: pytest.MonkeyPatch) -> None: + """``for_nams`` uses ``dataclasses.replace`` to inject ``settings`` into + a copy, not the caller's own config instance.""" + from neo4j_agent_memory.integrations.strands import ( + Neo4jMemoryStore, + Neo4jMemoryStoreConfig, + ) + + monkeypatch.setenv("MEMORY_API_KEY", "test-key") + original_config = Neo4jMemoryStoreConfig(name="graph") + + Neo4jMemoryStore.for_nams(original_config) + + assert original_config.settings is None class TestSinkResolution: @pytest.mark.asyncio async def test_creates_a_deterministically_named_sink(self) -> None: """Bolt keys conversations by session_id; the deterministic name is the whole contract.""" - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore client = FakeMemoryClient() - store = Neo4jMemoryStore(name="graph", client=client, user_id="alice") + store = _store(name="graph", client=client, user_id="alice") await store.initialize() key = await store._resolve_sink() @@ -137,14 +201,13 @@ async def test_creates_a_deterministically_named_sink(self) -> None: @pytest.mark.asyncio async def test_reuses_an_existing_sink_across_instances(self) -> None: """Bolt needs no round-trip for reuse: same name, same key, every time.""" - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore client = FakeMemoryClient() - first = Neo4jMemoryStore(name="graph", client=client) + first = _store(name="graph", client=client) await first.initialize() key_one = await first._resolve_sink() - second = Neo4jMemoryStore(name="graph", client=client) + second = _store(name="graph", client=client) await second.initialize() key_two = await second._resolve_sink() @@ -155,14 +218,13 @@ async def test_reuses_an_existing_sink_across_instances(self) -> None: @pytest.mark.asyncio async def test_reuses_the_nams_server_minted_id_by_metadata(self) -> None: """NAMS mints conversation ids, so reuse matches on metadata, not id.""" - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore client = FakeMemoryClient(nams_mode=True) - first = Neo4jMemoryStore(name="graph", client=client) + first = _store(name="graph", client=client) await first.initialize() key_one = await first._resolve_sink() - second = Neo4jMemoryStore(name="graph", client=client) + second = _store(name="graph", client=client) await second.initialize() key_two = await second._resolve_sink() @@ -175,10 +237,9 @@ async def test_reuses_the_nams_server_minted_id_by_metadata(self) -> None: @pytest.mark.asyncio async def test_nams_scans_conversations_once(self) -> None: """Symmetric to the bolt no-scan case: NAMS needs exactly one list round-trip.""" - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore client = FakeMemoryClient(nams_mode=True) - store = Neo4jMemoryStore(name="graph", client=client) + store = _store(name="graph", client=client) await store.initialize() await store._resolve_sink() @@ -186,10 +247,8 @@ async def test_nams_scans_conversations_once(self) -> None: @pytest.mark.asyncio async def test_explicit_conversation_id_is_used_verbatim(self) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - client = FakeMemoryClient() - store = Neo4jMemoryStore(name="graph", client=client, conversation_id="chat-42") + store = _store(name="graph", client=client, conversation_id="chat-42") await store.initialize() assert await store._resolve_sink() == "chat-42" @@ -197,11 +256,9 @@ async def test_explicit_conversation_id_is_used_verbatim(self) -> None: @pytest.mark.asyncio async def test_two_stores_with_different_names_get_different_sinks(self) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - client = FakeMemoryClient() - personal = Neo4jMemoryStore(name="personal", client=client) - team = Neo4jMemoryStore(name="team", client=client) + personal = _store(name="personal", client=client) + team = _store(name="team", client=client) await personal.initialize() await team.initialize() @@ -210,10 +267,9 @@ async def test_two_stores_with_different_names_get_different_sinks(self) -> None @pytest.mark.asyncio async def test_bolt_does_not_scan_conversations(self) -> None: """On bolt the deterministic name is the key; a list scan would be wasted work.""" - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore client = FakeMemoryClient() - store = Neo4jMemoryStore(name="graph", client=client) + store = _store(name="graph", client=client) await store.initialize() await store._resolve_sink() @@ -223,7 +279,6 @@ async def test_bolt_does_not_scan_conversations(self) -> None: class TestSearch: @pytest.mark.asyncio async def test_returns_memory_entries_with_metadata(self) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore from neo4j_agent_memory.memory.long_term import Entity, Preference client = FakeMemoryClient() @@ -232,7 +287,7 @@ async def test_returns_memory_entries_with_metadata(self) -> None: client.long_term.entities = [entity] client.long_term.preferences = [Preference(category="ui", preference="dark mode")] - store = Neo4jMemoryStore(name="graph", client=client) + store = _store(name="graph", client=client) await store.initialize() entries = await store.search("acme") @@ -246,12 +301,8 @@ async def test_returns_memory_entries_with_metadata(self) -> None: @pytest.mark.asyncio async def test_limit_precedence_call_then_store_then_default(self) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - client = FakeMemoryClient() - store = Neo4jMemoryStore( - name="graph", client=client, include_preferences=False, include_facts=False - ) + store = _store(name="graph", client=client, include_preferences=False, include_facts=False) await store.initialize() await store.search("q") @@ -271,7 +322,6 @@ async def test_limit_precedence_call_then_store_then_default(self) -> None: @pytest.mark.asyncio async def test_kind_flags_are_honoured(self) -> None: """All three kinds have data; only the enabled one should ever be searched.""" - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore from neo4j_agent_memory.memory.long_term import Entity, Fact, Preference client = FakeMemoryClient() @@ -279,7 +329,7 @@ async def test_kind_flags_are_honoured(self) -> None: client.long_term.preferences = [Preference(category="ui", preference="dark mode")] client.long_term.facts = [Fact(subject="Acme", predicate="located_in", object="NYC")] - store = Neo4jMemoryStore( + store = _store( name="graph", client=client, include_entities=False, @@ -299,14 +349,13 @@ async def test_kind_flags_are_honoured(self) -> None: @pytest.mark.asyncio async def test_nams_returns_entities_only(self) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore from neo4j_agent_memory.memory.long_term import Entity, Preference client = FakeMemoryClient(nams_mode=True) client.long_term.entities = [Entity(name="Acme Corp", type="ORGANIZATION")] client.long_term.preferences = [Preference(category="ui", preference="dark mode")] - store = Neo4jMemoryStore(name="graph", client=client) + store = _store(name="graph", client=client) await store.initialize() entries = await store.search("q") @@ -324,10 +373,9 @@ async def test_search_does_not_mint_a_sink(self) -> None: genuinely calls list_conversations (and possibly create_conversation), so this is the only client mode that can catch that regression. """ - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore client = FakeMemoryClient(nams_mode=True) - store = Neo4jMemoryStore(name="graph", client=client) + store = _store(name="graph", client=client) await store.initialize() await store.search("q") @@ -337,10 +385,9 @@ async def test_search_does_not_mint_a_sink(self) -> None: @pytest.mark.asyncio async def test_search_initializes_without_a_prior_initialize_call(self) -> None: """Standalone use (no MemoryManager.init_agent) must still connect.""" - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore client = FakeMemoryClient() - store = Neo4jMemoryStore(name="graph", client=client) + store = _store(name="graph", client=client) await store.search("q") assert client.connect_calls == 1 @@ -349,10 +396,8 @@ async def test_search_initializes_without_a_prior_initialize_call(self) -> None: class TestAdd: @pytest.mark.asyncio async def test_default_writes_a_message_into_the_sink_with_extraction(self) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - client = FakeMemoryClient() - store = Neo4jMemoryStore(name="graph", client=client) + store = _store(name="graph", client=client) await store.initialize() result = await store.add("The user prefers dark mode") @@ -364,10 +409,8 @@ async def test_default_writes_a_message_into_the_sink_with_extraction(self) -> N @pytest.mark.asyncio async def test_kind_preference_routes_to_add_preference(self) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - client = FakeMemoryClient() - store = Neo4jMemoryStore(name="graph", client=client) + store = _store(name="graph", client=client) await store.initialize() result = await store.add("dark mode", {"kind": "preference", "category": "ui"}) @@ -377,10 +420,8 @@ async def test_kind_preference_routes_to_add_preference(self) -> None: @pytest.mark.asyncio async def test_kind_preference_without_category_defaults_to_memory(self) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - client = FakeMemoryClient() - store = Neo4jMemoryStore(name="graph", client=client) + store = _store(name="graph", client=client) await store.initialize() await store.add("dark mode", {"kind": "preference"}) @@ -388,10 +429,8 @@ async def test_kind_preference_without_category_defaults_to_memory(self) -> None @pytest.mark.asyncio async def test_kind_fact_requires_a_triple(self) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - client = FakeMemoryClient() - store = Neo4jMemoryStore(name="graph", client=client) + store = _store(name="graph", client=client) await store.initialize() await store.add( @@ -405,10 +444,8 @@ async def test_kind_fact_requires_a_triple(self) -> None: @pytest.mark.asyncio async def test_kind_entity_routes_to_add_entity(self) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - client = FakeMemoryClient() - store = Neo4jMemoryStore(name="graph", client=client) + store = _store(name="graph", client=client) await store.initialize() await store.add("Acme Corp", {"kind": "entity", "type": "ORGANIZATION"}) @@ -417,10 +454,8 @@ async def test_kind_entity_routes_to_add_entity(self) -> None: @pytest.mark.asyncio async def test_kind_entity_defaults_name_and_type(self) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - client = FakeMemoryClient() - store = Neo4jMemoryStore(name="graph", client=client) + store = _store(name="graph", client=client) await store.initialize() await store.add("Acme Corp", {"kind": "entity"}) @@ -433,10 +468,9 @@ async def test_kind_entity_on_nams_returns_bare_entity_id(self) -> None: Without the isinstance narrowing in `_add_typed`, this raises trying to subscript a bare Entity as a tuple. """ - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore client = FakeMemoryClient(nams_mode=True) - store = Neo4jMemoryStore(name="graph", client=client) + store = _store(name="graph", client=client) await store.initialize() result = await store.add("Acme Corp", {"kind": "entity", "type": "ORGANIZATION"}) @@ -446,10 +480,8 @@ async def test_kind_entity_on_nams_returns_bare_entity_id(self) -> None: @pytest.mark.asyncio async def test_unsupported_kind_on_nams_falls_back_to_the_sink(self, caplog) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - client = FakeMemoryClient(nams_mode=True) - store = Neo4jMemoryStore(name="graph", client=client) + store = _store(name="graph", client=client) await store.initialize() result = await store.add("dark mode", {"kind": "preference", "category": "ui"}) @@ -459,10 +491,8 @@ async def test_unsupported_kind_on_nams_falls_back_to_the_sink(self, caplog) -> @pytest.mark.asyncio async def test_unsupported_kind_warning_is_logged_once_per_store(self, caplog) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - client = FakeMemoryClient(nams_mode=True) - store = Neo4jMemoryStore(name="graph", client=client) + store = _store(name="graph", client=client) await store.initialize() with caplog.at_level("WARNING"): @@ -475,9 +505,7 @@ async def test_unsupported_kind_warning_is_logged_once_per_store(self, caplog) - @pytest.mark.asyncio async def test_rejects_writes_when_not_writable(self) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - - store = Neo4jMemoryStore(name="graph", client=FakeMemoryClient(), writable=False) + store = _store(name="graph", client=FakeMemoryClient(), writable=False) await store.initialize() with pytest.raises(ValueError, match="not writable"): @@ -485,9 +513,7 @@ async def test_rejects_writes_when_not_writable(self) -> None: @pytest.mark.asyncio async def test_rejects_empty_content(self) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - - store = Neo4jMemoryStore(name="graph", client=FakeMemoryClient()) + store = _store(name="graph", client=FakeMemoryClient()) await store.initialize() with pytest.raises(ValueError, match="empty"): @@ -499,10 +525,8 @@ class TestAddMessages: async def test_writes_the_batch_to_the_sink_with_extraction(self) -> None: from strands.memory import AddMessagesContext - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - client = FakeMemoryClient() - store = Neo4jMemoryStore(name="graph", client=client) + store = _store(name="graph", client=client) await store.initialize() result = await store.add_messages( @@ -524,10 +548,8 @@ async def test_a_retried_batch_is_not_written_twice(self) -> None: """Extraction writes are at-least-once; the same sequence numbers repeat.""" from strands.memory import AddMessagesContext - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - client = FakeMemoryClient() - store = Neo4jMemoryStore(name="graph", client=client) + store = _store(name="graph", client=client) await store.initialize() batch = [{"role": "user", "content": [{"text": "ok"}]}] @@ -543,10 +565,8 @@ async def test_identical_text_with_distinct_sequence_numbers_is_kept(self) -> No """Dedupe keys on sequence number, not content — two 'ok's are two messages.""" from strands.memory import AddMessagesContext - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - client = FakeMemoryClient() - store = Neo4jMemoryStore(name="graph", client=client) + store = _store(name="graph", client=client) await store.initialize() result = await store.add_messages( @@ -561,10 +581,8 @@ async def test_identical_text_with_distinct_sequence_numbers_is_kept(self) -> No @pytest.mark.asyncio async def test_without_sequence_numbers_everything_is_written(self) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - client = FakeMemoryClient() - store = Neo4jMemoryStore(name="graph", client=client) + store = _store(name="graph", client=client) await store.initialize() batch = [{"role": "user", "content": [{"text": "ok"}]}] @@ -573,10 +591,8 @@ async def test_without_sequence_numbers_everything_is_written(self) -> None: @pytest.mark.asyncio async def test_messages_with_no_text_blocks_are_dropped(self) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - client = FakeMemoryClient() - store = Neo4jMemoryStore(name="graph", client=client) + store = _store(name="graph", client=client) await store.initialize() result = await store.add_messages( @@ -589,10 +605,8 @@ async def test_messages_with_no_text_blocks_are_dropped(self) -> None: @pytest.mark.asyncio async def test_batches_larger_than_100_are_chunked(self) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - client = FakeMemoryClient() - store = Neo4jMemoryStore(name="graph", client=client) + store = _store(name="graph", client=client) await store.initialize() messages = [{"role": "user", "content": [{"text": f"m{i}"}]} for i in range(250)] @@ -603,23 +617,54 @@ async def test_batches_larger_than_100_are_chunked(self) -> None: @pytest.mark.asyncio async def test_rejects_writes_when_not_writable(self) -> None: - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - - store = Neo4jMemoryStore(name="graph", client=FakeMemoryClient(), writable=False) + store = _store(name="graph", client=FakeMemoryClient(), writable=False) await store.initialize() with pytest.raises(ValueError, match="not writable"): await store.add_messages([{"role": "user", "content": [{"text": "x"}]}], None) + @pytest.mark.asyncio + async def test_bulk_kwargs_bind_against_the_real_bolt_signature(self) -> None: + """Regression guard for the ``user_identifier`` crash-on-bolt bug. + + The bug: the store passed ``user_identifier`` to ``bulk_add_messages`` + while the real ``ShortTermMemory.add_messages_batch`` had no such + parameter and no ``**kwargs`` catch-all, so bolt raised + ``TypeError: got an unexpected keyword argument 'user_identifier'``. + Fixed by adding the parameter to ``add_messages_batch`` itself (it now + enforces multi-tenancy and links the conversation, matching + ``add_message``), so the store keeps passing it. + + ``FakeShortTerm.bulk_add_messages`` now mirrors the real, explicit + parameter list (no ``**kwargs`` catch-all either), so it can no longer + swallow a keyword the real backend would reject — this suite would + have failed with the bug in place, and stays a live tripwire against + any future kwarg the store sends that the real signature doesn't + accept. Binding against the actual method's ``inspect.signature`` is + the belt-and-suspenders check on top of that fidelity fix. + """ + import inspect + + from neo4j_agent_memory.memory.short_term import ShortTermMemory + + client = FakeMemoryClient() + store = _store(name="graph", client=client, user_id="alice") + await store.initialize() + await store.add_messages([{"role": "user", "content": [{"text": "ok"}]}], None) + + call = client.short_term.bulk_calls[-1] + real_signature = inspect.signature(ShortTermMemory.add_messages_batch) + # `self` is positional-only for bind() purposes here; its value is + # never inspected, only its presence in the parameter list matters. + real_signature.bind(None, call["session_id"], call["messages"], **call["kwargs"]) + class TestWriteSinks: def test_declares_both_write_sinks(self) -> None: """Both sinks on one class: server-side extraction, `add` still available.""" from strands.memory.types import _has_method, _has_write_sink - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - - store = Neo4jMemoryStore(name="graph", client=FakeMemoryClient()) + store = _store(name="graph", client=FakeMemoryClient()) assert _has_method(store, "add") is True assert _has_method(store, "add_messages") is True @@ -635,9 +680,7 @@ def test_server_side_extraction_is_the_resolved_default(self) -> None: _resolve_extraction_config, ) - from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore - - store = Neo4jMemoryStore(name="graph", client=FakeMemoryClient(), extraction=True) + store = _store(name="graph", client=FakeMemoryClient(), extraction=True) resolved = _resolve_extraction_config(store.extraction, store) assert resolved is not None diff --git a/tests/unit/integrations/test_strands_retrieval_entries.py b/tests/unit/integrations/test_strands_retrieval_entries.py index 59b6c53f..03b6b14d 100644 --- a/tests/unit/integrations/test_strands_retrieval_entries.py +++ b/tests/unit/integrations/test_strands_retrieval_entries.py @@ -2,12 +2,17 @@ from __future__ import annotations +from typing import TYPE_CHECKING, cast + import pytest pytest.importorskip("strands", reason="strands-agents not installed") from neo4j_agent_memory.memory.long_term import Entity, Fact, Preference +if TYPE_CHECKING: + from neo4j_agent_memory.core.protocols import LongTermProtocol + def _entity() -> Entity: e = Entity(name="Acme Corp", type="ORGANIZATION") @@ -39,7 +44,7 @@ async def test_maps_each_kind_to_a_row_with_metadata(self) -> None: long_term.facts = [_fact()] rows = await _retrieve_entries( - long_term, + cast("LongTermProtocol", long_term), # fake implements only the searched subset "acme", limit=10, min_score=0.2, @@ -69,8 +74,13 @@ async def test_nams_gates_preferences_and_facts_off(self) -> None: long_term.preferences = [Preference(category="ui", preference="dark mode")] rows = await _retrieve_entries( - long_term, "q", limit=10, min_score=0.2, - include_entities=True, include_preferences=True, include_facts=True, + cast("LongTermProtocol", long_term), # fake implements only the searched subset + "q", + limit=10, + min_score=0.2, + include_entities=True, + include_preferences=True, + include_facts=True, nams=True, ) @@ -87,8 +97,13 @@ async def test_one_failing_kind_does_not_lose_the_others(self, caplog) -> None: long_term.fail_preferences = True rows = await _retrieve_entries( - long_term, "q", limit=10, min_score=0.2, - include_entities=True, include_preferences=True, include_facts=False, + cast("LongTermProtocol", long_term), # fake implements only the searched subset + "q", + limit=10, + min_score=0.2, + include_entities=True, + include_preferences=True, + include_facts=False, nams=False, ) @@ -103,11 +118,18 @@ async def test_missing_score_is_omitted_not_zero(self) -> None: long_term = FakeLongTerm() long_term.entities = [Entity(name="Acme Corp", type="ORGANIZATION")] # no similarity long_term.preferences = [Preference(category="ui", preference="dark mode")] # no similarity - long_term.facts = [Fact(subject="Ada", predicate="works_at", object="Acme")] # no similarity + long_term.facts = [ + Fact(subject="Ada", predicate="works_at", object="Acme") + ] # no similarity rows = await _retrieve_entries( - long_term, "q", limit=10, min_score=0.2, - include_entities=True, include_preferences=True, include_facts=True, + cast("LongTermProtocol", long_term), # fake implements only the searched subset + "q", + limit=10, + min_score=0.2, + include_entities=True, + include_preferences=True, + include_facts=True, nams=False, ) From 5000a81ff0c0086b7c1fa79b88f2410bf50e4125 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 20 Aug 2026 10:36:14 +0200 Subject: [PATCH 21/39] feat(strands): expose graph-native store tools get_entity_graph (get_related_entities on bolt, expand_graph on NAMS) and get_user_preferences (bolt only). Bound to the store's own client, and deliberately excluding search/add so nothing collides with the manager's search_memory / add_memory. --- CHANGELOG.md | 1 + .../integrations/strands/_store_tools.py | 118 ++++++++++++++++++ .../integrations/strands/memory_store.py | 13 ++ tests/unit/integrations/strands_fakes.py | 18 +++ .../integrations/test_strands_memory_store.py | 74 +++++++++++ 5 files changed, 224 insertions(+) create mode 100644 src/neo4j_agent_memory/integrations/strands/_store_tools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c364c2d..4dce5cf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Strands MemoryStore** (`Neo4jMemoryStore`) — cross-session recall for Strands agents via `MemoryManager(stores=[...])`: long-term search, plus writes that feed server-side extraction. Entities only on NAMS. Needs `strands-agents>=1.44.0`. + `get_tools()` adds `get_entity_graph` (multi-hop bolt, 1-hop NAMS) and, bolt-only, `get_user_preferences`. - **Strands SessionManager** (`Neo4jSessionManager`) — automatic conversation persistence/restore for AWS Strands agents via `Agent(session_manager=...)`, backed by any `MemoryClient` (bolt or NAMS). Includes opt-in long-term diff --git a/src/neo4j_agent_memory/integrations/strands/_store_tools.py b/src/neo4j_agent_memory/integrations/strands/_store_tools.py new file mode 100644 index 00000000..7f9df87d --- /dev/null +++ b/src/neo4j_agent_memory/integrations/strands/_store_tools.py @@ -0,0 +1,118 @@ +"""Graph-native @tool functions bound to one memory store's client. + +The tools a ``MemoryManager`` cannot provide: multi-hop traversal and +preference lookup. Deliberately excludes search/add, which the manager owns +as ``search_memory`` / ``add_memory`` — and ``add_memory`` is already the name +``context_graph_tools`` uses, so re-exposing it here would collide. + +Unlike ``tools.py``, these bind to the store's own client instead of the +factory's per-call cached clients, so nothing can close a transport the store +is still using. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +if TYPE_CHECKING: + from strands.types.tools import AgentTool + + from neo4j_agent_memory import MemoryClient + from neo4j_agent_memory.integrations.strands.memory_store import Neo4jMemoryStore + +_MAX_EDGES = 50 + + +async def _entity_graph( + client: MemoryClient, entity_name: str, *, depth: int, nams: bool +) -> dict[str, Any]: + """Return the neighbourhood of a named entity. + + bolt traverses ``get_related_entities`` to ``depth``; NAMS exposes only a + 1-hop ``expand_graph`` keyed by node id, so the name is resolved through + entity search first and the reported depth is 1. + """ + matches = await client.long_term.search_entities(entity_name, limit=1) + if not matches: + return {"error": f"entity not found: {entity_name}"} + centre = matches[0] + + # expand_graph (NAMS) and the depth kwarg on get_related_entities (bolt) + # are both outside LongTermProtocol's portable subset -- the nams flag + # already picks the right one at runtime, so cast past the protocol here. + long_term = cast(Any, client.long_term) + + if nams: + expansion = await long_term.expand_graph(str(centre.id)) + return { + "center": centre.display_name, + "depth": 1, + "nodes": list(expansion.get("nodes") or [])[:_MAX_EDGES], + "edges": list(expansion.get("edges") or [])[:_MAX_EDGES], + } + + related = await long_term.get_related_entities(centre, depth=depth) + nodes = [{"name": centre.display_name, "type": centre.type, "is_center": True}] + edges: list[dict[str, str]] = [] + for other, relationship in related[:_MAX_EDGES]: + nodes.append({"name": other.display_name, "type": other.type, "is_center": False}) + edges.append( + { + "from": other.display_name, + "relationship": getattr(relationship, "relationship_type", "RELATED_TO"), + "to": centre.display_name, + } + ) + return {"center": centre.display_name, "depth": depth, "nodes": nodes, "edges": edges} + + +async def _user_preferences( + client: MemoryClient, category: str | None, *, limit: int +) -> list[dict[str, Any]]: + """Return known preferences, optionally narrowed to one category.""" + preferences = await client.long_term.search_preferences( + category or "preference", limit=limit + ) + if category: + preferences = [p for p in preferences if p.category.lower() == category.lower()] + return [ + {"category": p.category, "preference": p.preference, "context": p.context} + for p in preferences + ] + + +def build_store_tools(store: Neo4jMemoryStore) -> list[AgentTool]: + """Build the store's graph tools, gated by what the backend exposes.""" + from strands import tool + + client = store._client + nams = store.is_nams + + @tool + async def get_entity_graph(entity_name: str, depth: int = 2) -> dict[str, Any]: + """Explore the graph neighbourhood of an entity. + + Use this to find how an entity connects to others — who works where, + what happened at which location. + + Args: + entity_name: The entity to start from. + depth: How many hops to traverse. Hosted backends traverse one hop + regardless. + """ + return await _entity_graph(client, entity_name, depth=max(1, min(depth, 3)), nams=nams) + + @tool + async def get_user_preferences(category: str | None = None, limit: int = 20) -> Any: + """Retrieve known user preferences, optionally filtered by category. + + Args: + category: Optional category such as "food" or "ui". + limit: Maximum preferences to return. + """ + return await _user_preferences(client, category, limit=limit) + + tools: list[AgentTool] = [get_entity_graph] + if not nams: + tools.append(get_user_preferences) + return tools diff --git a/src/neo4j_agent_memory/integrations/strands/memory_store.py b/src/neo4j_agent_memory/integrations/strands/memory_store.py index 5a9f746f..bcde6cf3 100644 --- a/src/neo4j_agent_memory/integrations/strands/memory_store.py +++ b/src/neo4j_agent_memory/integrations/strands/memory_store.py @@ -34,6 +34,7 @@ from strands.memory import ExtractionConfig from strands.types.content import Message as StrandsMessage + from strands.types.tools import AgentTool from neo4j_agent_memory import MemoryClient, MemorySettings from neo4j_agent_memory.nams.endpoints import TransportMode @@ -391,6 +392,18 @@ async def add_messages( self._written.update(token for token in tokens if token is not None) return {"written": len(payload), "skipped": skipped} + def get_tools(self) -> list[AgentTool]: + """Graph-native tools registered alongside the manager's own tools. + + Empty when ``graph_tools=False``. Never includes ``search_memory`` or + ``add_memory`` — those belong to the ``MemoryManager``. + """ + if not self.graph_tools: + return [] + from neo4j_agent_memory.integrations.strands._store_tools import build_store_tools + + return build_store_tools(self) + async def aclose(self) -> None: """Close the client only when the store constructed it.""" if self._owns_client: diff --git a/tests/unit/integrations/strands_fakes.py b/tests/unit/integrations/strands_fakes.py index 67a75bd3..7194081e 100644 --- a/tests/unit/integrations/strands_fakes.py +++ b/tests/unit/integrations/strands_fakes.py @@ -140,6 +140,10 @@ def __init__(self) -> None: self.added_facts: list[tuple[str, str, str]] = [] self.added_entities: list[tuple[str, str]] = [] self.nams_mode = False + self.related: list[tuple[Any, str]] = [] + self.related_kwargs: list[dict[str, Any]] = [] + self.expansion: dict[str, list[dict[str, Any]]] = {"nodes": [], "edges": []} + self.expand_calls: list[str] = [] async def _maybe_fail(self) -> None: if self.fail_searches: @@ -204,6 +208,20 @@ async def add_entity(self, name: str, entity_type: str, **kwargs: Any) -> Any: return entity return entity, None + async def get_related_entities(self, entity: Any, **kwargs: Any) -> list[tuple[Any, Any]]: + self._reject_on_nams("get_related_entities") + self.related_kwargs.append(kwargs) + + class _Rel: + def __init__(self, rel_type: str) -> None: + self.relationship_type = rel_type + + return [(other, _Rel(rel_type)) for other, rel_type in self.related] + + async def expand_graph(self, node_id: str, **kwargs: Any) -> dict[str, list[dict[str, Any]]]: + self.expand_calls.append(str(node_id)) + return self.expansion + class FakeReasoning: def __init__(self) -> None: diff --git a/tests/unit/integrations/test_strands_memory_store.py b/tests/unit/integrations/test_strands_memory_store.py index de1c743b..e92eae5b 100644 --- a/tests/unit/integrations/test_strands_memory_store.py +++ b/tests/unit/integrations/test_strands_memory_store.py @@ -685,3 +685,77 @@ def test_server_side_extraction_is_the_resolved_default(self) -> None: assert resolved is not None assert resolved.extractor is None + + +class TestGetTools: + def test_bolt_exposes_both_graph_tools(self) -> None: + store = _store(name="graph", client=FakeMemoryClient()) + names = {t.tool_name for t in store.get_tools()} + + assert names == {"get_entity_graph", "get_user_preferences"} + + def test_nams_omits_the_preferences_tool(self) -> None: + """NAMS exposes no preferences endpoint; expand_graph covers traversal.""" + store = _store(name="graph", client=FakeMemoryClient(nams_mode=True)) + names = {t.tool_name for t in store.get_tools()} + + assert names == {"get_entity_graph"} + + def test_graph_tools_false_exposes_nothing(self) -> None: + store = _store(name="graph", client=FakeMemoryClient(), graph_tools=False) + + assert store.get_tools() == [] + + def test_no_name_collision_with_the_managers_own_tools(self) -> None: + store = _store(name="graph", client=FakeMemoryClient()) + names = {t.tool_name for t in store.get_tools()} + + assert "search_memory" not in names + assert "add_memory" not in names + + @pytest.mark.asyncio + async def test_entity_graph_traverses_with_depth_on_bolt(self) -> None: + from neo4j_agent_memory.integrations.strands._store_tools import _entity_graph + from neo4j_agent_memory.memory.long_term import Entity + + client = FakeMemoryClient() + centre = Entity(name="Acme Corp", type="ORGANIZATION") + client.long_term.entities = [centre] + client.long_term.related = [(Entity(name="Ada", type="PERSON"), "WORKS_AT")] + + result = await _entity_graph(client, "Acme Corp", depth=2, nams=False) + + assert result["center"] == "Acme Corp" + assert {"from": "Ada", "relationship": "WORKS_AT", "to": "Acme Corp"} in result["edges"] + assert client.long_term.related_kwargs[-1]["depth"] == 2 + + @pytest.mark.asyncio + async def test_entity_graph_uses_expand_graph_on_nams(self) -> None: + """NAMS: name resolved via search, then a 1-hop expansion by node id.""" + from neo4j_agent_memory.integrations.strands._store_tools import _entity_graph + from neo4j_agent_memory.memory.long_term import Entity + + client = FakeMemoryClient(nams_mode=True) + centre = Entity(name="Acme Corp", type="ORGANIZATION") + client.long_term.entities = [centre] + client.long_term.expansion = { + "nodes": [{"id": "n2", "name": "Ada", "type": "PERSON"}], + "edges": [{"from": "n2", "to": str(centre.id), "type": "WORKS_AT"}], + } + + result = await _entity_graph(client, "Acme Corp", depth=3, nams=True) + + assert client.long_term.expand_calls == [str(centre.id)] + assert result["depth"] == 1 # 1 hop is all NAMS offers + assert result["nodes"] + + @pytest.mark.asyncio + async def test_entity_graph_reports_an_unknown_entity(self) -> None: + from neo4j_agent_memory.integrations.strands._store_tools import _entity_graph + + client = FakeMemoryClient() + client.long_term.entities = [] + + result = await _entity_graph(client, "Nobody", depth=1, nams=False) + + assert result["error"] == "entity not found: Nobody" From cd20791b655bd432f1e887b01233dc58c0fcd60f Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 20 Aug 2026 10:41:01 +0200 Subject: [PATCH 22/39] fix(strands): narrow store-tools casts to concrete backend classes Replace cast(Any, client.long_term) with cast("NamsLongTermMemory", ...) / cast("LongTermMemory", ...), scoped per branch, so the backend-specific expand_graph and get_related_entities(depth=...) calls stay type-checked instead of escaping mypy entirely. --- .../integrations/strands/_store_tools.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/neo4j_agent_memory/integrations/strands/_store_tools.py b/src/neo4j_agent_memory/integrations/strands/_store_tools.py index 7f9df87d..f4b42a0f 100644 --- a/src/neo4j_agent_memory/integrations/strands/_store_tools.py +++ b/src/neo4j_agent_memory/integrations/strands/_store_tools.py @@ -19,6 +19,8 @@ from neo4j_agent_memory import MemoryClient from neo4j_agent_memory.integrations.strands.memory_store import Neo4jMemoryStore + from neo4j_agent_memory.memory.long_term import LongTermMemory + from neo4j_agent_memory.nams.long_term import NamsLongTermMemory _MAX_EDGES = 50 @@ -37,13 +39,11 @@ async def _entity_graph( return {"error": f"entity not found: {entity_name}"} centre = matches[0] - # expand_graph (NAMS) and the depth kwarg on get_related_entities (bolt) - # are both outside LongTermProtocol's portable subset -- the nams flag - # already picks the right one at runtime, so cast past the protocol here. - long_term = cast(Any, client.long_term) - if nams: - expansion = await long_term.expand_graph(str(centre.id)) + # expand_graph is NAMS-only, excluded from LongTermProtocol -- cast to + # the concrete class so the call stays checked instead of untyped. + nams_long_term = cast("NamsLongTermMemory", client.long_term) + expansion = await nams_long_term.expand_graph(str(centre.id)) return { "center": centre.display_name, "depth": 1, @@ -51,7 +51,11 @@ async def _entity_graph( "edges": list(expansion.get("edges") or [])[:_MAX_EDGES], } - related = await long_term.get_related_entities(centre, depth=depth) + # get_related_entities' depth kwarg diverges from LongTermProtocol's + # portable, no-depth signature (core/protocols.py documents this as + # deliberate) -- cast to the concrete class so the call stays checked. + bolt_long_term = cast("LongTermMemory", client.long_term) + related = await bolt_long_term.get_related_entities(centre, depth=depth) nodes = [{"name": centre.display_name, "type": centre.type, "is_center": True}] edges: list[dict[str, str]] = [] for other, relationship in related[:_MAX_EDGES]: From fa1f7f41f49ff9d9883a03d063e0c7d6f9e2bacb Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 20 Aug 2026 10:56:18 +0200 Subject: [PATCH 23/39] fix(strands): scope get_user_preferences to the store's user get_user_preferences was calling search_preferences, which silently returns [] with no embedder and has no :User filter at all -- under multi_tenant it could leak another tenant's preferences. Switch to get_preferences_for, which is user-scoped and needs no embedder, and gate the tool on bolt + store.user_id being set (NAMS has no preferences endpoint; without a user_id there is nothing safe to scope to). Unscoped preference recall still reaches the model through the manager's own search_memory. Also tightens the get_tools test suite: drops a name-collision test fully subsumed by the exact-set assertion, asserts nodes/edges shapes precisely instead of truthiness, and adds depth-clamp and _MAX_EDGES-cap cases. --- CHANGELOG.md | 2 +- .../integrations/strands/_store_tools.py | 58 ++++++++----- tests/unit/integrations/strands_fakes.py | 7 ++ .../integrations/test_strands_memory_store.py | 85 ++++++++++++++++--- 4 files changed, 122 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dce5cf8..cd3639b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Strands MemoryStore** (`Neo4jMemoryStore`) — cross-session recall for Strands agents via `MemoryManager(stores=[...])`: long-term search, plus writes that feed server-side extraction. Entities only on NAMS. Needs `strands-agents>=1.44.0`. - `get_tools()` adds `get_entity_graph` (multi-hop bolt, 1-hop NAMS) and, bolt-only, `get_user_preferences`. + `get_tools()` adds `get_entity_graph` (multi-hop bolt, 1-hop NAMS) and, bolt-only with a configured user_id, the user-scoped `get_user_preferences`. - **Strands SessionManager** (`Neo4jSessionManager`) — automatic conversation persistence/restore for AWS Strands agents via `Agent(session_manager=...)`, backed by any `MemoryClient` (bolt or NAMS). Includes opt-in long-term diff --git a/src/neo4j_agent_memory/integrations/strands/_store_tools.py b/src/neo4j_agent_memory/integrations/strands/_store_tools.py index f4b42a0f..0fae4fd5 100644 --- a/src/neo4j_agent_memory/integrations/strands/_store_tools.py +++ b/src/neo4j_agent_memory/integrations/strands/_store_tools.py @@ -1,9 +1,10 @@ """Graph-native @tool functions bound to one memory store's client. The tools a ``MemoryManager`` cannot provide: multi-hop traversal and -preference lookup. Deliberately excludes search/add, which the manager owns -as ``search_memory`` / ``add_memory`` — and ``add_memory`` is already the name -``context_graph_tools`` uses, so re-exposing it here would collide. +scoped preference lookup. Deliberately excludes search/add, which the +manager owns as ``search_memory`` / ``add_memory`` — and ``add_memory`` is +already the name ``context_graph_tools`` uses, so re-exposing it here would +collide. Unlike ``tools.py``, these bind to the store's own client instead of the factory's per-call cached clients, so nothing can close a transport the store @@ -71,17 +72,23 @@ async def _entity_graph( async def _user_preferences( - client: MemoryClient, category: str | None, *, limit: int + client: MemoryClient, user_id: str, category: str | None, *, limit: int ) -> list[dict[str, Any]]: - """Return known preferences, optionally narrowed to one category.""" - preferences = await client.long_term.search_preferences( - category or "preference", limit=limit - ) + """Return the configured user's preferences, optionally narrowed to one category. + + ``get_preferences_for`` is user-scoped and needs no embedder, unlike + ``search_preferences`` (which returns ``[]`` with no embedder and no + ``:User`` filter at all -- both a silent-empty and a cross-tenant-leak + risk). It is not on ``LongTermProtocol`` either, so cast to the concrete + class. + """ + bolt_long_term = cast("LongTermMemory", client.long_term) + preferences = await bolt_long_term.get_preferences_for(user_id, active_only=True) if category: preferences = [p for p in preferences if p.category.lower() == category.lower()] return [ {"category": p.category, "preference": p.preference, "context": p.context} - for p in preferences + for p in preferences[:limit] ] @@ -106,17 +113,30 @@ async def get_entity_graph(entity_name: str, depth: int = 2) -> dict[str, Any]: """ return await _entity_graph(client, entity_name, depth=max(1, min(depth, 3)), nams=nams) - @tool - async def get_user_preferences(category: str | None = None, limit: int = 20) -> Any: - """Retrieve known user preferences, optionally filtered by category. + tools: list[AgentTool] = [get_entity_graph] - Args: - category: Optional category such as "food" or "ui". - limit: Maximum preferences to return. - """ - return await _user_preferences(client, category, limit=limit) + # get_preferences_for requires a user identifier and is bolt-only (NAMS + # has no preferences endpoint); an unscoped variant is exactly what + # risked leaking another tenant's preferences, so ship the tool only + # when both conditions hold. Unscoped recall still reaches the model + # through the manager's own search_memory, which includes preferences + # on bolt regardless. + if not nams and store.user_id: + user_id = store.user_id + + @tool + async def get_user_preferences(category: str | None = None, limit: int = 20) -> Any: + """Retrieve the configured user's preferences, optionally filtered by category. + + Returns only preferences belonging to this store's configured + user -- not a global listing across all users. + + Args: + category: Optional category such as "food" or "ui". + limit: Maximum preferences to return. + """ + return await _user_preferences(client, user_id, category, limit=limit) - tools: list[AgentTool] = [get_entity_graph] - if not nams: tools.append(get_user_preferences) + return tools diff --git a/tests/unit/integrations/strands_fakes.py b/tests/unit/integrations/strands_fakes.py index 7194081e..980d5d65 100644 --- a/tests/unit/integrations/strands_fakes.py +++ b/tests/unit/integrations/strands_fakes.py @@ -144,6 +144,8 @@ def __init__(self) -> None: self.related_kwargs: list[dict[str, Any]] = [] self.expansion: dict[str, list[dict[str, Any]]] = {"nodes": [], "edges": []} self.expand_calls: list[str] = [] + self.preferences_for: list[Any] = [] + self.preferences_for_calls: list[dict[str, Any]] = [] async def _maybe_fail(self) -> None: if self.fail_searches: @@ -222,6 +224,11 @@ async def expand_graph(self, node_id: str, **kwargs: Any) -> dict[str, list[dict self.expand_calls.append(str(node_id)) return self.expansion + async def get_preferences_for(self, user_identifier: str, **kwargs: Any) -> list[Any]: + self._reject_on_nams("get_preferences_for") + self.preferences_for_calls.append({"user_identifier": user_identifier, **kwargs}) + return self.preferences_for + class FakeReasoning: def __init__(self) -> None: diff --git a/tests/unit/integrations/test_strands_memory_store.py b/tests/unit/integrations/test_strands_memory_store.py index e92eae5b..52ca6aff 100644 --- a/tests/unit/integrations/test_strands_memory_store.py +++ b/tests/unit/integrations/test_strands_memory_store.py @@ -689,14 +689,21 @@ def test_server_side_extraction_is_the_resolved_default(self) -> None: class TestGetTools: def test_bolt_exposes_both_graph_tools(self) -> None: - store = _store(name="graph", client=FakeMemoryClient()) + store = _store(name="graph", client=FakeMemoryClient(), user_id="alice") names = {t.tool_name for t in store.get_tools()} assert names == {"get_entity_graph", "get_user_preferences"} + def test_bolt_omits_preferences_tool_without_a_user_id(self) -> None: + """get_preferences_for requires a user identifier; with none, no tool.""" + store = _store(name="graph", client=FakeMemoryClient()) + names = {t.tool_name for t in store.get_tools()} + + assert names == {"get_entity_graph"} + def test_nams_omits_the_preferences_tool(self) -> None: """NAMS exposes no preferences endpoint; expand_graph covers traversal.""" - store = _store(name="graph", client=FakeMemoryClient(nams_mode=True)) + store = _store(name="graph", client=FakeMemoryClient(nams_mode=True), user_id="alice") names = {t.tool_name for t in store.get_tools()} assert names == {"get_entity_graph"} @@ -706,13 +713,6 @@ def test_graph_tools_false_exposes_nothing(self) -> None: assert store.get_tools() == [] - def test_no_name_collision_with_the_managers_own_tools(self) -> None: - store = _store(name="graph", client=FakeMemoryClient()) - names = {t.tool_name for t in store.get_tools()} - - assert "search_memory" not in names - assert "add_memory" not in names - @pytest.mark.asyncio async def test_entity_graph_traverses_with_depth_on_bolt(self) -> None: from neo4j_agent_memory.integrations.strands._store_tools import _entity_graph @@ -726,9 +726,46 @@ async def test_entity_graph_traverses_with_depth_on_bolt(self) -> None: result = await _entity_graph(client, "Acme Corp", depth=2, nams=False) assert result["center"] == "Acme Corp" + assert {"name": "Ada", "type": "PERSON", "is_center": False} in result["nodes"] assert {"from": "Ada", "relationship": "WORKS_AT", "to": "Acme Corp"} in result["edges"] assert client.long_term.related_kwargs[-1]["depth"] == 2 + @pytest.mark.asyncio + async def test_entity_graph_depth_is_clamped_to_three_by_the_tool(self) -> None: + """The @tool wrapper clamps depth to [1, 3]; _entity_graph itself trusts its caller.""" + from neo4j_agent_memory.memory.long_term import Entity + + client = FakeMemoryClient() + centre = Entity(name="Acme Corp", type="ORGANIZATION") + client.long_term.entities = [centre] + + store = _store(name="graph", client=client, user_id="alice") + tools = {t.tool_name: t for t in store.get_tools()} + + await tools["get_entity_graph"](entity_name="Acme Corp", depth=99) + + assert client.long_term.related_kwargs[-1]["depth"] == 3 + + @pytest.mark.asyncio + async def test_entity_graph_caps_edges_at_max_edges_on_bolt(self) -> None: + from neo4j_agent_memory.integrations.strands._store_tools import ( + _MAX_EDGES, + _entity_graph, + ) + from neo4j_agent_memory.memory.long_term import Entity + + client = FakeMemoryClient() + centre = Entity(name="Acme Corp", type="ORGANIZATION") + client.long_term.entities = [centre] + client.long_term.related = [ + (Entity(name=f"Person {i}", type="PERSON"), "WORKS_AT") + for i in range(_MAX_EDGES + 10) + ] + + result = await _entity_graph(client, "Acme Corp", depth=1, nams=False) + + assert len(result["edges"]) == _MAX_EDGES + @pytest.mark.asyncio async def test_entity_graph_uses_expand_graph_on_nams(self) -> None: """NAMS: name resolved via search, then a 1-hop expansion by node id.""" @@ -747,7 +784,8 @@ async def test_entity_graph_uses_expand_graph_on_nams(self) -> None: assert client.long_term.expand_calls == [str(centre.id)] assert result["depth"] == 1 # 1 hop is all NAMS offers - assert result["nodes"] + assert result["nodes"] == client.long_term.expansion["nodes"] + assert result["edges"] == client.long_term.expansion["edges"] @pytest.mark.asyncio async def test_entity_graph_reports_an_unknown_entity(self) -> None: @@ -759,3 +797,30 @@ async def test_entity_graph_reports_an_unknown_entity(self) -> None: result = await _entity_graph(client, "Nobody", depth=1, nams=False) assert result["error"] == "entity not found: Nobody" + + @pytest.mark.asyncio + async def test_get_user_preferences_forwards_the_stores_user_id(self) -> None: + """Regression guard: a hard-coded or dropped user id must fail this.""" + client = FakeMemoryClient() + store = _store(name="graph", client=client, user_id="alice") + tools = {t.tool_name: t for t in store.get_tools()} + + await tools["get_user_preferences"]() + + assert client.long_term.preferences_for_calls[-1]["user_identifier"] == "alice" + + @pytest.mark.asyncio + async def test_get_user_preferences_category_filter_narrows_results(self) -> None: + from neo4j_agent_memory.memory.long_term import Preference + + client = FakeMemoryClient() + client.long_term.preferences_for = [ + Preference(category="food", preference="loves sushi"), + Preference(category="ui", preference="dark mode"), + ] + store = _store(name="graph", client=client, user_id="alice") + tools = {t.tool_name: t for t in store.get_tools()} + + result = await tools["get_user_preferences"](category="food") + + assert result == [{"category": "food", "preference": "loves sushi", "context": None}] From a684c0f7a99b3b6b86fafd9f19e8dcc2f0430921 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 20 Aug 2026 11:16:13 +0200 Subject: [PATCH 24/39] feat(strands): guard session-manager / memory-store overlaps Pairing both is supported and recommended. Two overlaps are not: double extraction raises (the store can only extract by re-writing turns the session manager already persisted, and NAMS extracts every write regardless of our flag), double injection warns once. Guards live here because MemoryStore.initialize() gets no agent, keeping the store free of coupling to strands internals. --- CHANGELOG.md | 1 + .../integrations/strands/session_manager.py | 58 ++++++ tests/unit/integrations/strands_fakes.py | 9 + .../integrations/test_strands_coexistence.py | 169 ++++++++++++++++++ 4 files changed, 237 insertions(+) create mode 100644 tests/unit/integrations/test_strands_coexistence.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cd3639b0..4e35a683 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - `strands` extra requires `strands-agents>=1.44.0` (was `>=0.1.0`). +- **`Neo4jSessionManager` now guards against a paired `Neo4jMemoryStore` duplicating its work**: raises if both would extract the same turns (always, on NAMS), warns once if both would inject context. - `ShortTermProtocol.bulk_add_messages` takes explicit keyword-only params (`generate_embeddings`, `extract_entities`, `extract_relations`, `user_identifier`) instead of `**kwargs`. diff --git a/src/neo4j_agent_memory/integrations/strands/session_manager.py b/src/neo4j_agent_memory/integrations/strands/session_manager.py index e6a0c9cb..d1a5000e 100644 --- a/src/neo4j_agent_memory/integrations/strands/session_manager.py +++ b/src/neo4j_agent_memory/integrations/strands/session_manager.py @@ -57,6 +57,12 @@ #: Conversation-metadata key linking a Conversation to a Strands session id. _SESSION_KEY = "strands_session_id" +#: Shared by the double-extraction ValueError and the double-injection warning. +_COEXISTENCE_HINT = ( + "Set extraction=False on Neo4jMemoryStore (recall only, recommended), or " + "extract_entities=False on Neo4jSessionManager (let the store own extraction)." +) + __all__ = ["Neo4jRetrievalConfig", "Neo4jSessionManager"] @@ -112,6 +118,7 @@ def __init__( self._last_persisted: StoredMessage | None = None # last stored (late redaction) self._trace_id: UUID | None = None # lazy reasoning trace (record_tool_calls) self._closed = False + self._warned_double_injection = False @property def _is_nams(self) -> bool: @@ -199,8 +206,59 @@ def _ensure_session(self) -> str: # ----------------------------------------------------- SessionManager API + def _our_stores(self, agent: Agent) -> list[Any]: + """Neo4jMemoryStore instances registered on the agent's MemoryManager. + + Reads ``MemoryManager._stores`` — a private, pinned by + ``test_strands_coexistence.py::TestPrivateAttributeCoupling``. + """ + manager = getattr(agent, "memory_manager", None) + if manager is None: + return [] + from neo4j_agent_memory.integrations.strands.memory_store import Neo4jMemoryStore + + stores = getattr(manager, "_stores", None) or [] + return [store for store in stores if isinstance(store, Neo4jMemoryStore)] + + def _check_memory_manager_coexistence(self, agent: Agent) -> None: + """Guard the two overlaps possible with a paired Neo4jMemoryStore. + + Pairing itself is supported: this manager persists/restores the + transcript, the store feeds the agent loop. Not supported: both + sides extracting the same turns, both sides injecting into the + same user message. + """ + stores = self._our_stores(agent) + if not stores: + return + + extracting = [store for store in stores if store.extraction] + if extracting: + # NAMS extracts every write server-side (add_message drops + # extraction kwargs there), so our own extract_entities flag + # cannot avert the duplication on that backend. + if self._extract_entities or self._is_nams: + raise ValueError( + f"Neo4jSessionManager and Neo4jMemoryStore " + f"'{extracting[0].name}' would write and extract the same " + f"turns twice. {_COEXISTENCE_HINT}" + ) + + if self._retrieval_config is not None and not self._warned_double_injection: + manager = getattr(agent, "memory_manager", None) + if getattr(manager, "_injection_config", None) is not False: + self._warned_double_injection = True + logger.warning( + "Memory is being injected twice: Neo4jRetrievalConfig on this " + "session manager and MemoryManager injection from " + "Neo4jMemoryStore '%s'. Drop retrieval_config, or pass " + "injection=False to MemoryManager.", + stores[0].name, + ) + def initialize(self, agent: Agent, **kwargs: Any) -> None: """Restore the agent's conversation history from the graph.""" + self._check_memory_manager_coexistence(agent) try: restored = self._bridge.run(self._ainitialize()) except Exception as e: diff --git a/tests/unit/integrations/strands_fakes.py b/tests/unit/integrations/strands_fakes.py index 980d5d65..000dacb6 100644 --- a/tests/unit/integrations/strands_fakes.py +++ b/tests/unit/integrations/strands_fakes.py @@ -289,3 +289,12 @@ async def connect(self) -> None: async def close(self) -> None: self.close_calls += 1 self._connected = False + + +class FakeAgent: + """Minimal Agent stand-in for the session manager's initialize(agent).""" + + def __init__(self, memory_manager: Any = None) -> None: + self.memory_manager = memory_manager + self.messages: list[Any] = [] + self.state: dict[str, Any] = {} diff --git a/tests/unit/integrations/test_strands_coexistence.py b/tests/unit/integrations/test_strands_coexistence.py new file mode 100644 index 00000000..f66834f3 --- /dev/null +++ b/tests/unit/integrations/test_strands_coexistence.py @@ -0,0 +1,169 @@ +"""Coexistence of Neo4jSessionManager and Neo4jMemoryStore on one agent. + +Both constructs are first-class Agent parameters, so pairing them is normal. +Two overlaps are not: double extraction (raises) and double injection (warns). +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +pytest.importorskip("strands", reason="strands-agents not installed") + +from tests.unit.integrations.strands_fakes import FakeAgent, FakeMemoryClient + + +def _manager_with_store(**store_kwargs: Any) -> Any: + from strands.memory import MemoryManager + + from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore, Neo4jMemoryStoreConfig + + store = Neo4jMemoryStore( + Neo4jMemoryStoreConfig(name="graph", client=FakeMemoryClient(), **store_kwargs) + ) + return MemoryManager(stores=[store]) + + +class TestDoubleExtraction: + def test_raises_when_both_sides_extract(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jSessionManager + + manager = Neo4jSessionManager( + "s1", memory_client=FakeMemoryClient(), extract_entities=True + ) + agent = FakeAgent(memory_manager=_manager_with_store(extraction=True)) + + with pytest.raises(ValueError) as excinfo: + manager.initialize(agent) + + message = str(excinfo.value) + assert "twice" in message + assert "extraction=False" in message + assert "extract_entities=False" in message + + def test_raises_on_nams_even_with_session_extraction_off(self) -> None: + """NAMS extracts server-side regardless of the session manager's flag.""" + from neo4j_agent_memory.integrations.strands import Neo4jSessionManager + + manager = Neo4jSessionManager( + "s1", memory_client=FakeMemoryClient(nams_mode=True), extract_entities=False + ) + agent = FakeAgent(memory_manager=_manager_with_store(extraction=True)) + + with pytest.raises(ValueError, match="twice"): + manager.initialize(agent) + + def test_allows_the_recommended_pairing(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jSessionManager + + manager = Neo4jSessionManager( + "s1", memory_client=FakeMemoryClient(), extract_entities=True + ) + agent = FakeAgent(memory_manager=_manager_with_store()) # extraction off + + manager.initialize(agent) # must not raise + + def test_allows_store_owned_extraction_when_session_manager_does_not(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jSessionManager + + manager = Neo4jSessionManager( + "s1", memory_client=FakeMemoryClient(), extract_entities=False + ) + agent = FakeAgent(memory_manager=_manager_with_store(extraction=True)) + + manager.initialize(agent) # must not raise + + def test_ignores_a_memory_manager_holding_only_foreign_stores(self) -> None: + from strands.memory import MemoryManager + from strands.vended_memory_stores.test_memory_store import TestMemoryStore + + from neo4j_agent_memory.integrations.strands import Neo4jSessionManager + + manager = Neo4jSessionManager( + "s1", memory_client=FakeMemoryClient(), extract_entities=True + ) + agent = FakeAgent(memory_manager=MemoryManager(stores=[TestMemoryStore(name="t")])) + + manager.initialize(agent) # not our store, not our problem + + def test_no_memory_manager_is_fine(self) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jSessionManager + + manager = Neo4jSessionManager("s1", memory_client=FakeMemoryClient()) + + manager.initialize(FakeAgent(memory_manager=None)) # must not raise + + +class TestDoubleInjection: + def test_warns_once_when_both_inject(self, caplog: pytest.LogCaptureFixture) -> None: + from neo4j_agent_memory.integrations.strands import ( + Neo4jRetrievalConfig, + Neo4jSessionManager, + ) + + manager = Neo4jSessionManager( + "s1", + memory_client=FakeMemoryClient(), + extract_entities=False, + retrieval_config=Neo4jRetrievalConfig(), + ) + agent = FakeAgent(memory_manager=_manager_with_store()) + + manager.initialize(agent) + manager.initialize(agent) + + assert caplog.text.lower().count("injected twice") == 1 + + def test_silent_without_retrieval_config(self, caplog: pytest.LogCaptureFixture) -> None: + from neo4j_agent_memory.integrations.strands import Neo4jSessionManager + + manager = Neo4jSessionManager( + "s1", memory_client=FakeMemoryClient(), extract_entities=False + ) + agent = FakeAgent(memory_manager=_manager_with_store()) + + manager.initialize(agent) + + assert "injected twice" not in caplog.text.lower() + + def test_silent_when_manager_injection_is_disabled( + self, caplog: pytest.LogCaptureFixture + ) -> None: + from strands.memory import MemoryManager + + from neo4j_agent_memory.integrations.strands import ( + Neo4jMemoryStore, + Neo4jMemoryStoreConfig, + Neo4jRetrievalConfig, + Neo4jSessionManager, + ) + + store = Neo4jMemoryStore(Neo4jMemoryStoreConfig(name="graph", client=FakeMemoryClient())) + manager = Neo4jSessionManager( + "s1", + memory_client=FakeMemoryClient(), + extract_entities=False, + retrieval_config=Neo4jRetrievalConfig(), + ) + agent = FakeAgent(memory_manager=MemoryManager(stores=[store], injection=False)) + + manager.initialize(agent) + + assert "injected twice" not in caplog.text.lower() + + +class TestPrivateAttributeCoupling: + def test_memory_manager_still_exposes_stores(self) -> None: + """Fails loudly if a strands upgrade moves MemoryManager._stores.""" + from strands.memory import MemoryManager + from strands.vended_memory_stores.test_memory_store import TestMemoryStore + + manager = MemoryManager(stores=[TestMemoryStore(name="t")]) + + assert hasattr(manager, "_stores"), ( + "MemoryManager._stores is gone; the coexistence guards in " + "session_manager.py need a new way to enumerate stores." + ) + assert [s.name for s in manager._stores] == ["t"] From 239a7cb2a3d47e738b5e5971edac9609b601b52b Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 20 Aug 2026 11:31:05 +0200 Subject: [PATCH 25/39] test(strands): strengthen foreign-store coexistence test test_ignores_a_memory_manager_holding_only_foreign_stores previously used extraction=False (the TestMemoryStore default), so it passed regardless of whether _our_stores filtered by isinstance or duck-typed on the extraction attribute. Turn extraction on for the foreign store so the test actually proves the isinstance(store, Neo4jMemoryStore) filter, not just that a non-extracting neighbor is harmless. --- tests/unit/integrations/test_strands_coexistence.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/unit/integrations/test_strands_coexistence.py b/tests/unit/integrations/test_strands_coexistence.py index f66834f3..e30ccbbf 100644 --- a/tests/unit/integrations/test_strands_coexistence.py +++ b/tests/unit/integrations/test_strands_coexistence.py @@ -76,6 +76,13 @@ def test_allows_store_owned_extraction_when_session_manager_does_not(self) -> No manager.initialize(agent) # must not raise def test_ignores_a_memory_manager_holding_only_foreign_stores(self) -> None: + """extraction=True on the foreign store, deliberately: the point is that + ``_our_stores`` filters by ``isinstance(store, Neo4jMemoryStore)``, not by + the extraction flag. A foreign store with extraction off would pass this + test even if the isinstance filter were replaced by duck-typing (e.g. + ``getattr(s, "extraction", False)``) — that regression must still fail + here, on a store that both extracts and isn't ours. + """ from strands.memory import MemoryManager from strands.vended_memory_stores.test_memory_store import TestMemoryStore @@ -84,7 +91,9 @@ def test_ignores_a_memory_manager_holding_only_foreign_stores(self) -> None: manager = Neo4jSessionManager( "s1", memory_client=FakeMemoryClient(), extract_entities=True ) - agent = FakeAgent(memory_manager=MemoryManager(stores=[TestMemoryStore(name="t")])) + agent = FakeAgent( + memory_manager=MemoryManager(stores=[TestMemoryStore(name="t", extraction=True)]) + ) manager.initialize(agent) # not our store, not our problem From d10c7a338977d47fe470e2a8dcb9654de3ed9bb4 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 20 Aug 2026 11:55:45 +0200 Subject: [PATCH 26/39] docs(strands): document the memory store as the preferred memory construct Guide leads with Neo4jMemoryStore; the memory-tools and Neo4jRetrievalConfig sections point at it for recall, plus the session-manager pairing shapes and a runnable no-API-key example (examples/strands-memory-store/). --- .../how-to/integrations/aws-strands.adoc | 291 +++++++++++++++--- examples/strands-memory-store/README.md | 97 ++++++ examples/strands-memory-store/main.py | 73 +++++ tests/docs/test_code_snippets.py | 2 + .../test_strands_memory_store_example.py | 88 ++++++ .../test_strands_memory_store_integration.py | 61 ++++ 6 files changed, 564 insertions(+), 48 deletions(-) create mode 100644 examples/strands-memory-store/README.md create mode 100644 examples/strands-memory-store/main.py create mode 100644 tests/examples/test_strands_memory_store_example.py create mode 100644 tests/integration/test_strands_memory_store_integration.py diff --git a/docs/modules/ROOT/pages/how-to/integrations/aws-strands.adoc b/docs/modules/ROOT/pages/how-to/integrations/aws-strands.adoc index ecd75c85..25ffaf3a 100644 --- a/docs/modules/ROOT/pages/how-to/integrations/aws-strands.adoc +++ b/docs/modules/ROOT/pages/how-to/integrations/aws-strands.adoc @@ -1,6 +1,6 @@ = AWS Strands Agents Integration :description: Integrate Neo4j Agent Memory with AWS Strands Agents SDK -:keywords: strands, aws, bedrock, neo4j, agent, tools, session manager +:keywords: strands, aws, bedrock, neo4j, agent, tools, session manager, memory store [NOTE] ==== @@ -12,25 +12,31 @@ wires up different Strands surfaces (`SnapshotStorage`, ==== This guide shows how to add Neo4j Context Graph memory to AWS Strands agents. -The integration offers two complementary modes, usable independently or +In Strands' own vocabulary: the session manager restores sessions, the +memory store feeds the agent loop. Three constructs, usable independently or together on the same agent: [cols="1,2,2"] |=== -| Mode | What it does | Reach for it when +| Construct | What it does | Reach for it when -| *Memory tools* (pull-based) -| `@tool` functions the agent calls on its own judgment: search memories, - explore the entity graph, store facts, read preferences. -| You want the agent in explicit control — deep queries, on-demand recall, - storing extra-conversational facts. +| *Memory store* (`MemoryStore`) +| `Neo4jMemoryStore` on `MemoryManager(stores=[...])`: long-term recall + (entities, preferences, facts) injected into the agent loop, plus writes + that feed server-side extraction. +| The preferred way to give an agent memory. Requires `strands-agents>=1.44.0`. | *Session manager* (push-based) | `Neo4jSessionManager` on `Agent(session_manager=...)`: every turn is - persisted automatically, history is restored on restart, and (opt-in) - relevant long-term memories are injected into each user message. -| You want zero-effort conversation capture and continuity — nothing depends + persisted automatically and history is restored on restart. +| You want zero-effort transcript capture and continuity — nothing depends on the model remembering to call a tool. + +| *Memory tools* (pull-based) +| `@tool` functions the agent calls on its own judgment: search memories, + explore the entity graph, store facts, read preferences. +| Deep graph queries the store doesn't cover, or `strands-agents<1.44` + (no `MemoryStore`). |=== [[_overview]] @@ -40,9 +46,9 @@ image::diagrams/multi-agent-architecture-aws-neo4j.png[Multi-agent architecture image::diagrams/shared-memory-data-flow-kyc-graph-credit.png[Shared memory data flow: KYC agent writes entities to Neo4j, Credit agent reads them — findings are immediately available across agents,width=540,align=left] -Both modes write into the same knowledge graph, which is what enables the -multi-agent "shared brain" shown above: what one agent learns, every other -agent can retrieve (see <<_the_shared_brain_pattern>>). +All three constructs write into the same knowledge graph, which is what +enables the multi-agent "shared brain" shown above: what one agent learns, +every other agent can retrieve (see <<_the_shared_brain_pattern>>). == Installation @@ -53,79 +59,250 @@ pip install neo4j-agent-memory[aws,strands] == Quick Start -This example combines both modes: the session manager captures and restores -the conversation transparently, while the tools give the agent explicit -memory operations. +`Neo4jMemoryStore` is the preferred way to give a Strands agent memory — +long-term recall injected straight into the agent loop: [source,python] ---- from strands import Agent +from strands.memory import MemoryManager + from neo4j_agent_memory import MemorySettings +from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore, Neo4jMemoryStoreConfig + +settings = MemorySettings( + neo4j={"uri": "neo4j+s://xxx.databases.neo4j.io", "password": "your-password"}, +) +store = Neo4jMemoryStore( + Neo4jMemoryStoreConfig(name="graph", settings=settings, user_id="user-123") +) + +agent = Agent( + model="anthropic.claude-sonnet-4-20250514-v1:0", + memory_manager=MemoryManager(stores=[store]), +) + +agent("Remember that I prefer Python over JavaScript") +---- + +Add `Neo4jSessionManager` for transcript persistence/restore (see +<<_pairing_with_the_session_manager>>) and `context_graph_tools` for deep +graph queries the store doesn't cover — all three are independent and +combine on one agent: + +[source,python] +---- from neo4j_agent_memory.integrations.strands import ( - Neo4jRetrievalConfig, Neo4jSessionManager, context_graph_tools, ) -# Pull-based tools — the agent decides when to search or store. tools = context_graph_tools( neo4j_uri="neo4j+s://xxx.databases.neo4j.io", neo4j_password="your-password", embedding_provider="bedrock", ) -# Push-based session manager — every turn persisted, history restored, -# relevant memories injected into each user message (opt-in). -manager = Neo4jSessionManager( - "support-42", - settings=MemorySettings( - neo4j={ - "uri": "neo4j+s://xxx.databases.neo4j.io", - "password": "your-password", - }, - ), - retrieval_config=Neo4jRetrievalConfig(), -) +# extract_entities=True: the session manager owns extraction here: the +# store's own extraction stays off (its default) — see the pairing rule. +manager = Neo4jSessionManager("support-42", settings=settings, extract_entities=True) agent = Agent( model="anthropic.claude-sonnet-4-20250514-v1:0", tools=tools, session_manager=manager, + memory_manager=MemoryManager(stores=[store]), ) - -agent("Remember that I prefer Python over JavaScript") ---- -Using the hosted NAMS service instead of self-hosted Neo4j? Swap in the -NAMS variants — both read `MEMORY_API_KEY` (and optionally -`MEMORY_ENDPOINT`) from the environment: +Using the hosted NAMS service instead of self-hosted Neo4j? `for_nams()` on +the store and the session manager, and `nams_context_graph_tools()`, all +read `MEMORY_API_KEY` (and optionally `MEMORY_ENDPOINT`) from the +environment: [source,python] ---- from neo4j_agent_memory.integrations.strands import ( - Neo4jRetrievalConfig, + Neo4jMemoryStore, + Neo4jMemoryStoreConfig, Neo4jSessionManager, nams_context_graph_tools, ) -manager = Neo4jSessionManager.for_nams( - "support-42", - retrieval_config=Neo4jRetrievalConfig(), -) +store = Neo4jMemoryStore.for_nams(Neo4jMemoryStoreConfig(name="graph")) +manager = Neo4jSessionManager.for_nams("support-42") tools = nams_context_graph_tools() ---- [IMPORTANT] ==== -Do *not* share a `MemoryClient` between the tools and the session manager. -The tools use their own cached clients with per-call open/close semantics; -the session manager holds a persistent client on its own event-loop thread. -Sharing one client would let a tool's teardown close the manager's transport -mid-session. Two transports per process is correct and cheap. +Do *not* share a `MemoryClient` between the tools factory, the session +manager, and the store. Each holds its own client with different lifecycle +semantics — sharing one risks a teardown closing another's transport +mid-session. Multiple transports per process is correct and cheap. ==== -Each mode also works on its own — the next two sections cover them -independently. +Each construct also works on its own — the following sections cover the +store, the tools, and the session manager independently. + +[[_memory_store]] +== Memory Store + +`Neo4jMemoryStore` implements Strands' `MemoryStore` protocol. Hand it to +`MemoryManager(stores=[...])` on `Agent(memory_manager=...)` and the manager +searches it for context and routes `add_memory`/programmatic `add()` calls +to it. + +=== Configuration + +`Neo4jMemoryStoreConfig` is a dataclass — every field is a checked +attribute, not a dict key. Pass exactly one of `client` (a pre-connected +`MemoryClient`, left open) or `settings` (the store builds and owns one). + +[cols="1,1,3"] +|=== +| Field | Default | Purpose + +| `name` +| _(required)_ +| Store name; also names the deterministic sink conversation. + +| `client` \| `settings` +| — +| Exactly one of a pre-connected `MemoryClient`, or `MemorySettings` to build one from. + +| `description` +| auto +| Shown to the model as the store's purpose. + +| `max_search_results` +| `None` +| Per-store cap; falls back to the manager's default (3) when unset. + +| `writable` +| `True` +| `False` disables `add()`/`add_messages()`. + +| `extraction` +| `False` +| Truthy enables store-side extraction. Leave `False` (recall-only) when paired with `Neo4jSessionManager` — see <<_pairing_with_the_session_manager>>. + +| `conversation_id` +| minted +| Explicit write-sink override; otherwise a deterministic name is minted in `initialize()`. + +| `user_id` +| `None` +| Scopes reads/writes to one tenant; gates the `get_user_preferences` tool. + +| `include_entities` / `include_preferences` / `include_facts` +| `True` +| Search fan-out. Preferences and facts are auto-gated off on NAMS (`NotSupportedError`). + +| `min_score` +| `0.2` +| Similarity floor. bolt only — NAMS ignores it. + +| `graph_tools` +| `True` +| Whether `get_tools()` returns anything. +|=== + +[source,python] +---- +from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore, Neo4jMemoryStoreConfig + +store = Neo4jMemoryStore( + Neo4jMemoryStoreConfig(name="graph", client=client, user_id="user-123") +) +---- + +=== search(), add(), add_messages() + +`search(query)` fans out over entities, preferences, and facts and returns +`MemoryEntry` objects: `content` is a formatted line, `metadata` carries +`kind` (`entity`/`preference`/`fact`), `id`, `type`, and — bolt only — +`score`. **Entities only on NAMS**: `search_preferences`/`search_facts` +raise `NotSupportedError` there, so those two kinds are dropped from the +fan-out automatically. + +`add(content, metadata=None)` writes a message into the store's sink +conversation with extraction on by default — the one write path every +backend supports. Set `metadata["kind"]` to `"preference"`, `"fact"`, or +`"entity"` to route to a typed write (`add_preference`/`add_fact`/`add_entity`) +instead; a kind unsupported on the current backend (e.g. `"fact"` on NAMS) +falls back to the default sink and logs a warning once per store. + +`add_messages(messages, context)` bulk-ingests a batch of conversation turns +into the sink with extraction on — server-side on NAMS, inline on bolt — +so no extra model call happens. This is what a `MemoryManager` with +`extraction` truthy calls automatically. + +=== Graph tools + +`get_tools()` returns graph-native tools the manager itself can't provide, +bound to the store's own client: + +[cols="1,1,1,3"] +|=== +| Tool | bolt | NAMS | Notes + +| `get_entity_graph` +| yes +| yes +| Configurable `depth` on bolt (`get_related_entities`); NAMS traverses one hop only (`expand_graph`, keyed by node id — the entity name is resolved via search first). + +| `get_user_preferences` +| yes, if `user_id` set +| omitted +| Bolt-only, and only shipped when the store has a configured `user_id` (scopes the lookup to that tenant). NAMS has no preferences endpoint. +|=== + +[[_pairing_with_the_session_manager]] +=== Pairing with the session manager + +The session manager restores sessions; the memory store feeds the agent +loop — different jobs, and pairing both on one agent is supported and +recommended: + +[cols="1,3"] +|=== +| Shape | Behaviour + +| Store alone +| Store owns extraction (`extraction=True` or an explicit extractor) — the textbook Strands split. + +| Store + session manager (recommended) +| Store stays recall-only (`extraction=False`, the default); the session manager persists the transcript and extracts (bolt in place, NAMS server-side). + +| Store + session manager, with the store also extracting +| Raises at construction — both would write and extract the same turns twice. +|=== + +Recommended configuration: + +[source,python] +---- +Neo4jSessionManager(..., extract_entities=True) # transcript + extraction +Neo4jMemoryStore(Neo4jMemoryStoreConfig(name="graph")) # recall only (extraction=False default) +---- + +If both extract, `Neo4jSessionManager` raises at agent construction: + +---- +Neo4jSessionManager and Neo4jMemoryStore 'graph' would write and extract the +same turns twice. Set extraction=False on Neo4jMemoryStore (recall only, +recommended), or extract_entities=False on Neo4jSessionManager (let the +store own extraction). +---- + +Separately, `Neo4jSessionManager`'s own `Neo4jRetrievalConfig` and the +manager's default injection both fold context into the last user message — +pairing them injects memory twice. The session manager logs a warning +(once) naming the fix; see <<_retrieval_injection>>. + +See `examples/strands-memory-store/` for a runnable demo (`search()`, +`add()`, `get_tools()`) that requires no LLM or API key. == Memory Tools (Pull-Based) @@ -148,6 +325,13 @@ The integration provides four memory tools that agents can use: | Retrieve user preferences by category |=== +[NOTE] +==== +`search_context` is superseded by `Neo4jMemoryStore` (<<_memory_store>>) for +recall. The factory remains supported for deep graph work the store doesn't +cover. +==== + [TIP] ==== When a session manager is attached (see <<_session_manager_push_based>>), @@ -426,8 +610,19 @@ Alternatively, pass an already-constructed (but not yet connected) `MemoryClient` via `memory_client=`; see the loop-binding note under <<_limitations>>. +[[_retrieval_injection]] === Retrieval Injection +[NOTE] +==== +For new code, `MemoryManager` injection (via `Neo4jMemoryStore`, see +<<_memory_store>>) supersedes `Neo4jRetrievalConfig`. Both together inject +memory twice; the session manager logs a warning when it detects the +combination. `Neo4jRetrievalConfig` remains fully supported — it is the only +injection path on `strands-agents<1.44` and configures its sources +declaratively rather than through a store. +==== + When a `Neo4jRetrievalConfig` is supplied, each user message triggers concurrent long-term searches (entities, preferences, and optionally facts). Matching results are prepended inside a `` block: diff --git a/examples/strands-memory-store/README.md b/examples/strands-memory-store/README.md new file mode 100644 index 00000000..bd055ef4 --- /dev/null +++ b/examples/strands-memory-store/README.md @@ -0,0 +1,97 @@ +# Strands MemoryStore — long-term recall + +![Neo4j Labs](https://img.shields.io/badge/Neo4j-Labs-6366F1?logo=neo4j) +![Status: Beta](https://img.shields.io/badge/Status-Beta-6366F1) +![Community Supported](https://img.shields.io/badge/Support-Community-6B7280) + +> `Neo4jMemoryStore` implements Strands' `MemoryStore` protocol: pass it to +> `MemoryManager(stores=[...])` and the agent loop recalls entities, +> preferences, and facts from a Neo4j graph across sessions. + +> ⚠️ **Neo4j Labs Project** +> +> This example is part of [`neo4j-agent-memory`](https://github.com/neo4j-labs/agent-memory), a Neo4j Labs project. It is actively maintained but not officially supported. APIs may change. Community support is available via the [Neo4j Community Forum](https://community.neo4j.com). + +## What this demonstrates + +- **`search()`** — fans out over entities, preferences, and facts (entities + only on hosted NAMS), returning `MemoryEntry` objects with a formatted + `content` string and a `metadata["kind"]` tag. +- **`add()`** — default sink writes a message with extraction on; + `metadata["kind"]` (`"preference"` / `"fact"` / `"entity"`) routes to a + typed write instead. +- **`get_tools()`** — graph-native tools (`get_entity_graph`, and, bolt-only + with a configured `user_id`, `get_user_preferences`) that a `MemoryManager` + cannot provide on its own. + +`Neo4jSessionManager` (`examples/strands-session-manager/`) remains for +transcript persistence — the session manager restores sessions, the memory +store feeds the agent loop. See the guide's "Pairing with the session +manager" section for combining both on one agent. + +## Prerequisites + +- Neo4j 5.x running at `bolt://localhost:7687` + (or set `NEO4J_URI`, `NEO4J_USERNAME`, `NEO4J_PASSWORD`). +- `neo4j-agent-memory[strands]` installed: + ```bash + uv pip install "neo4j-agent-memory[strands]" + # or, for the local sentence-transformers embedder: + uv pip install "neo4j-agent-memory[strands,sentence-transformers]" + ``` + +## Run + +```bash +make neo4j-start +NEO4J_PASSWORD=test-password uv run python examples/strands-memory-store/main.py +``` + +No LLM API key required — `llm=None` plus a local `sentence-transformers` +embedder. + +Expected output: + +``` +search('what does the user prefer?'): + entity: [entity] Acme Corp (ORGANIZATION) + preference: [preference] ui: Prefers dark mode +add(...): {'kind': 'message', 'id': '...'} +tools: ['get_entity_graph', 'get_user_preferences'] +``` + +## With a real agent + +```python +from strands import Agent +from strands.memory import MemoryManager + +from neo4j_agent_memory.integrations.strands import ( + Neo4jMemoryStore, + Neo4jMemoryStoreConfig, +) + +store = Neo4jMemoryStore(Neo4jMemoryStoreConfig(name="graph", client=client)) + +agent = Agent( + model="anthropic.claude-sonnet-4-20250514-v1:0", + memory_manager=MemoryManager(stores=[store]), +) +``` + +## Files + +| File | Purpose | +|---|---| +| `main.py` | Seeds a preference and an entity, then exercises `search()`, `add()`, and `get_tools()`. | + +## Going further + +- **How-to guide:** `docs/modules/ROOT/pages/how-to/integrations/aws-strands.adoc` + — configuration, backend differences, and the session-manager pairing rule. + +## Support + +- 💬 [Neo4j Community Forum](https://community.neo4j.com) +- 🐛 [GitHub Issues](https://github.com/neo4j-labs/agent-memory/issues) +- 📖 [`neo4j-agent-memory` documentation](https://github.com/neo4j-labs/agent-memory#readme) diff --git a/examples/strands-memory-store/main.py b/examples/strands-memory-store/main.py new file mode 100644 index 00000000..6fbb50cb --- /dev/null +++ b/examples/strands-memory-store/main.py @@ -0,0 +1,73 @@ +"""Neo4jMemoryStore for Strands -- long-term recall, no API keys required. + +Neo4jMemoryStore is a Strands `MemoryStore`: hand it to +`MemoryManager(stores=[...])` for cross-session recall fed into the agent +loop. Neo4jSessionManager (examples/strands-session-manager/) remains for +transcript persistence -- the two are complementary, not alternatives; see +the "Pairing with the session manager" section of the guide. + + make neo4j-start + NEO4J_PASSWORD=test-password uv run python examples/strands-memory-store/main.py +""" + +from __future__ import annotations + +import asyncio +import os + +from pydantic import SecretStr + +from neo4j_agent_memory import MemoryClient, MemorySettings, Neo4jConfig +from neo4j_agent_memory.config.settings import ExtractionConfig, ExtractorType +from neo4j_agent_memory.integrations.strands import ( + Neo4jMemoryStore, + Neo4jMemoryStoreConfig, +) + + +def build_settings() -> MemorySettings: + """Local sentence-transformers embedder, no LLM -- runs with no API key. + + ``ExtractorType.NONE`` disables entity extraction so the demo works + whether or not spaCy / GLiNER extras are installed; the entity and + preference below are seeded directly instead. + """ + return MemorySettings( + neo4j=Neo4jConfig( + uri=os.getenv("NEO4J_URI", "bolt://localhost:7687"), + username=os.getenv("NEO4J_USERNAME", "neo4j"), + password=SecretStr(os.getenv("NEO4J_PASSWORD", "test-password")), + ), + llm=None, + embedding="sentence-transformers/all-MiniLM-L6-v2", + extraction=ExtractionConfig(extractor_type=ExtractorType.NONE), + ) + + +async def main() -> None: + async with MemoryClient(build_settings()) as client: + await client.long_term.add_preference("ui", "Prefers dark mode") + await client.long_term.add_entity("Acme Corp", "ORGANIZATION") + + store = Neo4jMemoryStore( + Neo4jMemoryStoreConfig(name="graph", client=client, user_id="alice") + ) + await store.initialize() + + print("search('what does the user prefer?'):") + for entry in await store.search("what does the user prefer?"): + kind = (entry.metadata or {}).get("kind", "?") + print(f" {kind:>10}: {entry.content}") + + # Default sink: written as a message with extraction on -- the one + # write path every backend supports. metadata["kind"] routes to a + # typed write (preference/fact/entity) instead; see the guide. + result = await store.add("The user's deployment target is us-east-1") + print(f"add(...): {result}") + + # Graph-native tools a MemoryManager can't provide on its own. + print("tools:", [tool.tool_name for tool in store.get_tools()]) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/docs/test_code_snippets.py b/tests/docs/test_code_snippets.py index e3598d2c..a73327b9 100644 --- a/tests/docs/test_code_snippets.py +++ b/tests/docs/test_code_snippets.py @@ -241,6 +241,8 @@ def test_neo4j_agent_memory_imports_exist(self, python_snippets: list[CodeSnippe "nams_context_graph_tools", "Neo4jSessionManager", "Neo4jRetrievalConfig", + "Neo4jMemoryStore", + "Neo4jMemoryStoreConfig", "HybridMemoryProvider", "StrandsConfig", "MemoryType", diff --git a/tests/examples/test_strands_memory_store_example.py b/tests/examples/test_strands_memory_store_example.py new file mode 100644 index 00000000..39a4503f --- /dev/null +++ b/tests/examples/test_strands_memory_store_example.py @@ -0,0 +1,88 @@ +"""Smoke tests for the strands-memory-store example.""" + +from __future__ import annotations + +import ast +import importlib.util +import sys +from pathlib import Path + +import pytest + +pytest.importorskip("strands", reason="strands-agents not installed") + +EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples" +STRANDS_MS_DIR = EXAMPLES_DIR / "strands-memory-store" + + +@pytest.mark.syntax +class TestStrandsMemoryStoreStructure: + def test_required_files_exist(self): + for filename in ["README.md", "main.py"]: + assert (STRANDS_MS_DIR / filename).exists(), f"Missing: {filename}" + + def test_main_compiles(self): + ast.parse((STRANDS_MS_DIR / "main.py").read_text(encoding="utf-8")) + + +@pytest.mark.imports +class TestStrandsMemoryStoreImports: + def test_required_imports_resolve(self): + from neo4j_agent_memory import MemorySettings # noqa: F401 + from neo4j_agent_memory.integrations.strands import ( # noqa: F401 + Neo4jMemoryStore, + Neo4jMemoryStoreConfig, + ) + + def test_example_module_imports(self): + """The module must be importable and expose a callable main().""" + spec = importlib.util.spec_from_file_location( + "strands_ms_example", STRANDS_MS_DIR / "main.py" + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(module) # imports must succeed; main() not called + assert callable(module.main) + finally: + sys.modules.pop("strands_ms_example", None) + + def test_build_settings_structure(self, monkeypatch): + """build_settings() must produce a MemorySettings with no LLM.""" + pytest.importorskip("sentence_transformers") + + monkeypatch.setenv("NEO4J_URI", "bolt://localhost:7687") + monkeypatch.setenv("NEO4J_USERNAME", "neo4j") + monkeypatch.setenv("NEO4J_PASSWORD", "password") + + spec = importlib.util.spec_from_file_location( + "strands_ms_example_settings", STRANDS_MS_DIR / "main.py" + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(module) + settings = module.build_settings() + # No LLM — runs without API keys. + assert settings.llm is None + finally: + sys.modules.pop("strands_ms_example_settings", None) + + +@pytest.mark.syntax +class TestStrandsMemoryStoreContent: + def test_uses_the_public_store_api(self): + source = (STRANDS_MS_DIR / "main.py").read_text(encoding="utf-8") + assert ( + "from neo4j_agent_memory.integrations.strands import" in source + and "Neo4jMemoryStore" in source + ) + assert "Neo4jMemoryStoreConfig" in source, "construction is dataclass-config based" + assert "llm=None" in source, "the example must run without an API key" + + def test_calls_search_add_and_get_tools(self): + source = (STRANDS_MS_DIR / "main.py").read_text(encoding="utf-8") + assert "store.search(" in source + assert "store.add(" in source + assert "store.get_tools(" in source + assert "store.initialize(" in source diff --git a/tests/integration/test_strands_memory_store_integration.py b/tests/integration/test_strands_memory_store_integration.py new file mode 100644 index 00000000..f43da7e3 --- /dev/null +++ b/tests/integration/test_strands_memory_store_integration.py @@ -0,0 +1,61 @@ +"""Neo4jMemoryStore against a real Neo4j (bolt).""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("strands", reason="strands-agents not installed") + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_round_trip_recall(clean_memory_client) -> None: + from neo4j_agent_memory.integrations.strands import ( + Neo4jMemoryStore, + Neo4jMemoryStoreConfig, + ) + + await clean_memory_client.long_term.add_preference("ui", "Prefers dark mode") + + store = Neo4jMemoryStore(Neo4jMemoryStoreConfig(name="graph", client=clean_memory_client)) + await store.initialize() + entries = await store.search("dark mode") + + assert any("dark mode" in entry.content for entry in entries) + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_add_messages_reuses_the_same_sink_across_instances(clean_memory_client) -> None: + """Two store instances with the same name/user_id resolve to one sink. + + On bolt the sink name is deterministic (``strands-memory-store/{user}/{name}``) + and *is* the conversation key — no backend lookup is needed to find it, unlike + NAMS where ids are server-minted and the sink is found by matching metadata. + This proves the bolt path: no duplicate sink conversation is created, and both + instances' writes land in the one conversation. + """ + from neo4j_agent_memory.integrations.strands import ( + Neo4jMemoryStore, + Neo4jMemoryStoreConfig, + ) + + batch = [{"role": "user", "content": [{"text": "I work at Acme Corp"}]}] + + first = Neo4jMemoryStore(Neo4jMemoryStoreConfig(name="graph", client=clean_memory_client)) + await first.initialize() + await first.add_messages(batch, None) + + second = Neo4jMemoryStore(Neo4jMemoryStoreConfig(name="graph", client=clean_memory_client)) + await second.initialize() + await second.add_messages(batch, None) + + sink_name = await first._resolve_sink() + assert await second._resolve_sink() == sink_name + + conversations = await clean_memory_client.short_term.list_conversations(limit=100) + matching = [c for c in conversations if c.session_id == sink_name] + assert len(matching) == 1, "exactly one sink conversation, not one per store instance" + + conversation = await clean_memory_client.short_term.get_conversation(sink_name) + assert len(conversation.messages) == 2, "both instances' batches landed in the one sink" From fba1f532af6f0d885cff4e718511d140db3c7b79 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 20 Aug 2026 12:16:18 +0200 Subject: [PATCH 27/39] fix(strands): clear guidance for stale vector indexes on the shared container Neo4jMemoryStore's example uses a 384-dim embedder; the docker-compose container's named volume survives neo4j-stop/start, so a prior example run leaves indexes sized wrong for the integration suite's 1536-dim MockEmbedder. Add a fixture that detects this and skips with the actual cause and fix (make neo4j-clean) instead of a buried generic message, and a README note. Also: correct the pairing-table's unconditional "raises" claim (bolt + extract_entities=False does not raise), and add the missing doc-import-resolution test for the memory-store guide section. --- .../how-to/integrations/aws-strands.adoc | 2 +- examples/strands-memory-store/README.md | 6 ++ tests/docs/test_code_snippets.py | 7 ++ .../test_strands_memory_store_integration.py | 71 +++++++++++++++++++ 4 files changed, 85 insertions(+), 1 deletion(-) diff --git a/docs/modules/ROOT/pages/how-to/integrations/aws-strands.adoc b/docs/modules/ROOT/pages/how-to/integrations/aws-strands.adoc index 25ffaf3a..9522468b 100644 --- a/docs/modules/ROOT/pages/how-to/integrations/aws-strands.adoc +++ b/docs/modules/ROOT/pages/how-to/integrations/aws-strands.adoc @@ -276,7 +276,7 @@ recommended: | Store stays recall-only (`extraction=False`, the default); the session manager persists the transcript and extracts (bolt in place, NAMS server-side). | Store + session manager, with the store also extracting -| Raises at construction — both would write and extract the same turns twice. +| Raises at construction — both would write and extract the same turns twice. Exception: bolt with the session manager's own `extract_entities=False` does not raise, since then neither side extracts locally. |=== Recommended configuration: diff --git a/examples/strands-memory-store/README.md b/examples/strands-memory-store/README.md index bd055ef4..5afd496a 100644 --- a/examples/strands-memory-store/README.md +++ b/examples/strands-memory-store/README.md @@ -50,6 +50,12 @@ NEO4J_PASSWORD=test-password uv run python examples/strands-memory-store/main.py No LLM API key required — `llm=None` plus a local `sentence-transformers` embedder. +The container's data volume persists across `make neo4j-stop` / `neo4j-start` +(only `neo4j-clean` wipes it). If this container previously ran against a +different-dimension embedder (e.g. OpenAI's 1536-dim default), connecting +here fails with `EmbeddingDimensionMismatchError`, not a silent problem — +run `make neo4j-clean && make neo4j-start` to reset. + Expected output: ``` diff --git a/tests/docs/test_code_snippets.py b/tests/docs/test_code_snippets.py index a73327b9..c91208c7 100644 --- a/tests/docs/test_code_snippets.py +++ b/tests/docs/test_code_snippets.py @@ -359,6 +359,13 @@ def test_strands_session_manager_doc_imports_resolve(self) -> None: nams_context_graph_tools, ) + def test_strands_memory_store_doc_imports_resolve(self) -> None: + pytest.importorskip("strands", reason="strands-agents not installed") + from neo4j_agent_memory.integrations.strands import ( # noqa: F401 + Neo4jMemoryStore, + Neo4jMemoryStoreConfig, + ) + @pytest.mark.docs class TestSnippetConsistency: diff --git a/tests/integration/test_strands_memory_store_integration.py b/tests/integration/test_strands_memory_store_integration.py index f43da7e3..1cff593e 100644 --- a/tests/integration/test_strands_memory_store_integration.py +++ b/tests/integration/test_strands_memory_store_integration.py @@ -6,6 +6,77 @@ pytest.importorskip("strands", reason="strands-agents not installed") +#: Dimensionality of ``tests/conftest.py``'s ``MockEmbedder``, which +#: ``clean_memory_client`` wires in for this module's tests. +_EXPECTED_DIMENSIONS = 1536 + +#: Same set as ``SchemaManager._MANAGED_VECTOR_INDEXES`` +#: (``src/neo4j_agent_memory/graph/schema.py``). +_MANAGED_VECTOR_INDEXES = ( + "entity_embedding_idx", + "fact_embedding_idx", + "message_embedding_idx", + "preference_embedding_idx", + "step_embedding_idx", + "task_embedding_idx", +) + + +@pytest.fixture(autouse=True) +def _skip_on_incompatible_vector_indexes(neo4j_connection_info) -> None: + """Skip with a clear reason if the container's schema won't fit this suite. + + The docker-compose Neo4j container (``docker-compose.test.yml``) uses a + *named* volume: data and vector indexes survive ``make neo4j-stop`` / + ``neo4j-start`` (only ``make neo4j-clean`` wipes it). If + ``examples/strands-memory-store/`` (a 384-dim sentence-transformers + embedder) was run against the same container, its vector indexes are + sized for 384 dimensions. ``clean_memory_client`` then connects with the + 1536-dim ``MockEmbedder`` and ``MemoryClient.connect()`` raises + ``EmbeddingDimensionMismatchError`` deep inside that fixture's generic + ``except Exception: pytest.skip(f"Neo4j not available: {e}")`` — a + reason that reads as "no Neo4j" when the real story is "wrong schema". + + This checks the raw index dimensions first (bypassing ``MemoryClient`` + and its embedder entirely) so the skip names the actual cause and the + fix. It only reads ``SHOW VECTOR INDEXES`` — it never drops or modifies + anything, since these indexes may serve other embedders the developer + is intentionally running against this container. + """ + from neo4j import GraphDatabase + + driver = GraphDatabase.driver( + neo4j_connection_info["uri"], + auth=(neo4j_connection_info["username"], neo4j_connection_info["password"]), + ) + try: + with driver.session() as session: + rows = session.run( + "SHOW VECTOR INDEXES YIELD name, options RETURN name AS name, options AS options" + ).data() + finally: + driver.close() + + mismatched = [] + for row in rows: + name = row.get("name") + if name not in _MANAGED_VECTOR_INDEXES: + continue + config = (row.get("options") or {}).get("indexConfig") or {} + dims = config.get("vector.dimensions") + if isinstance(dims, int) and dims != _EXPECTED_DIMENSIONS: + mismatched.append(f"{name} ({dims}d)") + + if mismatched: + pytest.skip( + f"Neo4j at {neo4j_connection_info['uri']!r} has vector indexes sized " + f"for a different embedder than this suite's {_EXPECTED_DIMENSIONS}-dim " + f"MockEmbedder: {', '.join(mismatched)}. Likely cause: a prior run of " + "examples/strands-memory-store/ (384-dim sentence-transformers) against " + "this same persistent container. Fix: `make neo4j-clean` (drops the " + "container's data volume), then `make neo4j-start`." + ) + @pytest.mark.integration @pytest.mark.asyncio From 8422708e4664804ea9def8e1e6db3be49719606e Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 20 Aug 2026 12:51:50 +0200 Subject: [PATCH 28/39] fix(strands): rebind an owned client when Strands changes the event loop Agent.__init__ initializes the store through strands._async.run_async (asyncio.run in a throwaway thread) and every Agent.__call__ uses a different loop. The neo4j async driver and the NAMS transport bind to the loop that opened them, and initialize() returned early on _initialized, so the guide's Quick Start raised "Task ... attached to a different loop" from inside the driver on the first real call. initialize() now records the running loop and, on a change: - owned client (settings=): close and reconnect on the current loop. One reconnect per synchronous Agent(...) invocation. - borrowed client (client=): raise, naming the problem and both remedies. Closing or reconnecting someone else's client is not ours to do. aclose() resets the initialization state so re-entering the async context manager reconnects, and tolerates a client whose loop has already gone. Also drops the write-only _conversation_id attribute (_sink_key carries the value) and trims the config docstring's argument against the rejected TypedDict design, which belongs in the spec. --- CHANGELOG.md | 4 + .../integrations/strands/memory_store.py | 95 ++++++++++++++++--- .../test_strands_memory_store_integration.py | 50 ++++++++++ .../integrations/test_strands_memory_store.py | 76 +++++++++++++++ 4 files changed, 213 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e35a683..f8a749ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 agents via `MemoryManager(stores=[...])`: long-term search, plus writes that feed server-side extraction. Entities only on NAMS. Needs `strands-agents>=1.44.0`. `get_tools()` adds `get_entity_graph` (multi-hop bolt, 1-hop NAMS) and, bolt-only with a configured user_id, the user-scoped `get_user_preferences`. + A store built from `settings=` owns its client and rebinds it when the event + loop changes (Strands' synchronous `Agent(...)` runs every call on a fresh + loop); a client passed as `client=` is never closed or reconnected, and a + loop change raises a named error instead of an opaque driver `RuntimeError`. - **Strands SessionManager** (`Neo4jSessionManager`) — automatic conversation persistence/restore for AWS Strands agents via `Agent(session_manager=...)`, backed by any `MemoryClient` (bolt or NAMS). Includes opt-in long-term diff --git a/src/neo4j_agent_memory/integrations/strands/memory_store.py b/src/neo4j_agent_memory/integrations/strands/memory_store.py index bcde6cf3..8bedf52e 100644 --- a/src/neo4j_agent_memory/integrations/strands/memory_store.py +++ b/src/neo4j_agent_memory/integrations/strands/memory_store.py @@ -7,6 +7,7 @@ from __future__ import annotations +import asyncio import logging import uuid from dataclasses import dataclass, replace @@ -54,20 +55,25 @@ __all__ = ["Neo4jMemoryStore", "Neo4jMemoryStoreConfig"] +def _running_loop() -> asyncio.AbstractEventLoop | None: + """The running loop, or ``None`` outside one.""" + try: + return asyncio.get_running_loop() + except RuntimeError: + return None + + @dataclass class Neo4jMemoryStoreConfig: """Configuration for :class:`Neo4jMemoryStore`. - A plain dataclass, not a ``TypedDict``: every field is a checked - attribute (``config.user_id``, never ``config.get("user_id")``), so a - typo is a ``mypy --strict`` error rather than a silently-``None`` read. - Reuse one config across several stores — personal / team / org — with - ``dataclasses.replace(config, name="team")``. - ``name``, ``description``, ``max_search_results``, ``writable`` and ``extraction`` are the fields ``MemoryStore``'s protocol requires the store to expose as instance attributes; the rest are Neo4j connection, scoping, and search knobs. + + Reuse one config across several stores — personal / team / org — with + ``dataclasses.replace(config, name="team")``. """ name: str @@ -136,12 +142,12 @@ def __init__(self, config: Neo4jMemoryStoreConfig) -> None: self._include_facts = config.include_facts self._min_score = config.min_score - self._conversation_id = config.conversation_id self._sink_key: str | None = config.conversation_id self._owns_client = config.client is None self._run_id = uuid.uuid4().hex self._written: set[tuple[str, int]] = set() self._initialized = False + self._loop: asyncio.AbstractEventLoop | None = None self._warned_unsupported_kinds: set[str] = set() if config.client is not None: @@ -225,11 +231,56 @@ async def _resolve_sink(self) -> str: return sink_key async def initialize(self) -> None: - """Connect the client if not already connected. Idempotent.""" + """Connect the client, rebinding it if the event loop changed. + + Idempotent per loop. The neo4j async driver and the NAMS HTTP + transport both bind to the loop that opened them, while Strands' + synchronous entry points run every call on a fresh, throwaway loop + (``strands._async.run_async`` is ``asyncio.run`` in a worker + thread): ``Agent.__init__`` initializes the store on one loop and + each ``Agent.__call__`` drives it from another. A client bound to + the first loop then raises ``RuntimeError: ... attached to a + different loop`` from deep inside the driver. + + So: record the loop we connected on, and when it changes, + + * **owned client** (built from ``settings=``) — close and reconnect + on the current loop. One reconnect per synchronous ``Agent(...)`` + invocation is the price of that entry point. + * **borrowed client** (passed as ``client=``) — raise. Closing or + reconnecting someone else's client is not ours to do, and a clear + error beats the driver's opaque one. + """ + loop = asyncio.get_running_loop() + if self._initialized: - return - if not self._client.is_connected: + if self._loop is loop: + return + if not self._owns_client: + raise RuntimeError( + f"Neo4jMemoryStore '{self.name}': the MemoryClient passed as " + "client= was connected on a different event loop and its " + "transport cannot be driven from this one. Strands' synchronous " + "Agent(...) entry point runs each call on a fresh loop, so a " + "borrowed client only works when your own loop drives the agent. " + "Either hand the store a MemoryClient connected on the loop that " + "drives the agent, or construct the store from settings= so it " + "owns its client and can rebind it." + ) + try: + await self._client.close() + except Exception as error: # noqa: BLE001 - the old loop is usually gone + logger.debug( + "Neo4jMemoryStore '%s': closing the client bound to the previous " + "event loop failed (expected once that loop is gone): %s", + self.name, + error, + ) await self._client.connect() + elif not self._client.is_connected: + await self._client.connect() + + self._loop = loop self._initialized = True async def search(self, query: str, options: SearchOptions | None = None) -> list[MemoryEntry]: @@ -405,9 +456,29 @@ def get_tools(self) -> list[AgentTool]: return build_store_tools(self) async def aclose(self) -> None: - """Close the client only when the store constructed it.""" + """Close the client only when the store constructed it. + + Resets the initialization state either way, so re-entering the + async context manager reconnects instead of short-circuiting on a + stale ``_initialized``. + """ if self._owns_client: - await self._client.close() + stale = self._loop is not None and self._loop is not _running_loop() + try: + await self._client.close() + except Exception: + # A client bound to a loop that has since closed cannot be + # shut down cleanly from here — its sockets went with the + # loop. Anywhere else, the caller deserves the error. + if not stale: + raise + logger.debug( + "Neo4jMemoryStore '%s': client bound to a closed event loop; " + "skipping its shutdown.", + self.name, + ) + self._initialized = False + self._loop = None async def __aenter__(self) -> Neo4jMemoryStore: await self.initialize() diff --git a/tests/integration/test_strands_memory_store_integration.py b/tests/integration/test_strands_memory_store_integration.py index 1cff593e..961ed65c 100644 --- a/tests/integration/test_strands_memory_store_integration.py +++ b/tests/integration/test_strands_memory_store_integration.py @@ -130,3 +130,53 @@ async def test_add_messages_reuses_the_same_sink_across_instances(clean_memory_c conversation = await clean_memory_client.short_term.get_conversation(sink_name) assert len(conversation.messages) == 2, "both instances' batches landed in the one sink" + + +@pytest.mark.integration +def test_an_owned_client_survives_the_loop_change_strands_forces(neo4j_connection_info) -> None: + """The Quick Start's shape: ``settings=``, driven by synchronous ``Agent(...)``. + + ``Agent.__init__`` calls ``MemoryManager.init_agent`` -> ``store.initialize()`` + through ``strands._async.run_async`` (``asyncio.run`` in a throwaway thread), + and every ``Agent.__call__`` runs on a *different* loop. The neo4j async + driver stays bound to the loop that opened it, so before the rebind in + ``initialize()`` this raised ``RuntimeError: Task ... attached to a + different loop`` from deep inside the driver. + + Real work, not ``search()``: with no embedder ``search()`` short-circuits + to ``[]`` without touching the driver, so it would pass either way. + """ + from pydantic import SecretStr + from strands._async import run_async + + from neo4j_agent_memory import MemorySettings + from neo4j_agent_memory.config.settings import Neo4jConfig + from neo4j_agent_memory.integrations.strands import ( + Neo4jMemoryStore, + Neo4jMemoryStoreConfig, + ) + + settings = MemorySettings( + neo4j=Neo4jConfig( + uri=neo4j_connection_info["uri"], + username=neo4j_connection_info["username"], + password=SecretStr(neo4j_connection_info["password"]), + ) + ) + store = Neo4jMemoryStore(Neo4jMemoryStoreConfig(name="loop-rebind", settings=settings)) + + # Loop A — what Agent.__init__ does. + run_async(store.initialize) + connecting_loop = store._loop + assert connecting_loop is not None + + # Loop B — what Agent.__call__ does. + async def real_work() -> list[dict[str, object]]: + await store.initialize() + return await store._client.query.cypher("RETURN 1 AS n") + + try: + assert run_async(real_work) == [{"n": 1}] + assert store._loop is not connecting_loop, "the store rebound to the new loop" + finally: + run_async(store.aclose) diff --git a/tests/unit/integrations/test_strands_memory_store.py b/tests/unit/integrations/test_strands_memory_store.py index 52ca6aff..b09f0a1f 100644 --- a/tests/unit/integrations/test_strands_memory_store.py +++ b/tests/unit/integrations/test_strands_memory_store.py @@ -141,6 +141,82 @@ async def test_context_manager_closes_an_owned_client(self) -> None: store._client.close.assert_awaited_once() + @pytest.mark.asyncio + async def test_aclose_resets_initialization_so_reentry_reconnects(self) -> None: + """Re-entering the context manager must reconnect, not skip on a stale flag. + + Ownership comes from the constructor (``settings=``) as it must; only + the transport underneath is swapped for a call-counting fake, so the + store still closes on exit and has to reconnect on re-entry. + """ + from pydantic import SecretStr + + from neo4j_agent_memory import MemorySettings + from neo4j_agent_memory.config.settings import Neo4jConfig + + store = _store( + name="graph", settings=MemorySettings(neo4j=Neo4jConfig(password=SecretStr("p"))) + ) + client = FakeMemoryClient() + store._client = client # type: ignore[assignment] + + async with store: + pass + async with store: + pass + + assert client.connect_calls == 2 + assert client.close_calls == 2 + + +class TestEventLoopRebinding: + """Strands' synchronous entry points drive each call on a fresh loop. + + ``Agent.__init__`` runs ``initialize()`` through + ``strands._async.run_async`` (``asyncio.run`` in a throwaway thread) and + every ``Agent.__call__`` uses a *different* loop. The neo4j driver and + the NAMS transport bind to the loop that opened them, so the store has + to notice the change. See ``tests/integration/`` for the same scenario + end-to-end against a real Neo4j. + """ + + def test_a_borrowed_client_raises_a_named_error_on_a_new_loop(self) -> None: + """A client we were handed is not ours to close or reconnect — so: raise. + + The error has to name the problem and both remedies; the alternative + is an opaque ``RuntimeError`` from inside the neo4j driver. + """ + from strands._async import run_async + + client = FakeMemoryClient() + store = _store(name="graph", client=client) + + run_async(store.initialize) + + with pytest.raises(RuntimeError, match="different event loop") as raised: + run_async(store.initialize) + + message = str(raised.value) + assert "settings=" in message and "client=" in message + # Untouched: neither closed nor reconnected behind the owner's back. + assert client.close_calls == 0 + assert client.connect_calls == 1 + + def test_the_same_loop_stays_idempotent(self) -> None: + """Only a *changed* loop triggers the rebind path.""" + from strands._async import run_async + + client = FakeMemoryClient() + store = _store(name="graph", client=client) + + async def twice() -> None: + await store.initialize() + await store.initialize() + + run_async(twice) + + assert client.connect_calls == 1 + class TestForNams: def test_builds_nams_settings_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: From d2aadfd270c57f481f44ac0a83429e05751ef18d Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 20 Aug 2026 12:52:06 +0200 Subject: [PATCH 29/39] style: ruff format the three files failing CI's format gate .github/workflows/ci-python.yml runs `ruff format --check src tests` as a blocking step. `make lint` is only `ruff check` and does not cover it, so these three drifted. No behaviour change: `ruff format` output only. --- .../integrations/strands/_retrieval.py | 27 ++++++++++++++++--- .../integrations/test_strands_coexistence.py | 12 +++------ .../integrations/test_strands_memory_store.py | 3 +-- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/src/neo4j_agent_memory/integrations/strands/_retrieval.py b/src/neo4j_agent_memory/integrations/strands/_retrieval.py index 392ebbfa..cec675be 100644 --- a/src/neo4j_agent_memory/integrations/strands/_retrieval.py +++ b/src/neo4j_agent_memory/integrations/strands/_retrieval.py @@ -113,11 +113,23 @@ def _row( def _entity_row(entity: Entity) -> _EntryRow: - return _row("entity", entity.id, entity.full_type or entity.type, entity.metadata, _format_entity(entity)) + return _row( + "entity", + entity.id, + entity.full_type or entity.type, + entity.metadata, + _format_entity(entity), + ) def _preference_row(preference: Preference) -> _EntryRow: - return _row("preference", preference.id, preference.category, preference.metadata, _format_preference(preference)) + return _row( + "preference", + preference.id, + preference.category, + preference.metadata, + _format_preference(preference), + ) def _fact_row(fact: Fact) -> _EntryRow: @@ -141,9 +153,16 @@ async def _retrieve_entries( the others' hits. NAMS has no preference/fact search endpoints, so those are skipped rather than raised on every call. """ - wanted: list[tuple[str, bool, Callable[..., Awaitable[list[Any]]], Callable[..., _EntryRow]]] = [ + wanted: list[ + tuple[str, bool, Callable[..., Awaitable[list[Any]]], Callable[..., _EntryRow]] + ] = [ ("entity", include_entities, long_term.search_entities, _entity_row), - ("preference", include_preferences and not nams, long_term.search_preferences, _preference_row), + ( + "preference", + include_preferences and not nams, + long_term.search_preferences, + _preference_row, + ), ("fact", include_facts and not nams, long_term.search_facts, _fact_row), ] active = [(kind, search, row) for kind, on, search, row in wanted if on] diff --git a/tests/unit/integrations/test_strands_coexistence.py b/tests/unit/integrations/test_strands_coexistence.py index e30ccbbf..3d4fa79c 100644 --- a/tests/unit/integrations/test_strands_coexistence.py +++ b/tests/unit/integrations/test_strands_coexistence.py @@ -30,9 +30,7 @@ class TestDoubleExtraction: def test_raises_when_both_sides_extract(self) -> None: from neo4j_agent_memory.integrations.strands import Neo4jSessionManager - manager = Neo4jSessionManager( - "s1", memory_client=FakeMemoryClient(), extract_entities=True - ) + manager = Neo4jSessionManager("s1", memory_client=FakeMemoryClient(), extract_entities=True) agent = FakeAgent(memory_manager=_manager_with_store(extraction=True)) with pytest.raises(ValueError) as excinfo: @@ -58,9 +56,7 @@ def test_raises_on_nams_even_with_session_extraction_off(self) -> None: def test_allows_the_recommended_pairing(self) -> None: from neo4j_agent_memory.integrations.strands import Neo4jSessionManager - manager = Neo4jSessionManager( - "s1", memory_client=FakeMemoryClient(), extract_entities=True - ) + manager = Neo4jSessionManager("s1", memory_client=FakeMemoryClient(), extract_entities=True) agent = FakeAgent(memory_manager=_manager_with_store()) # extraction off manager.initialize(agent) # must not raise @@ -88,9 +84,7 @@ def test_ignores_a_memory_manager_holding_only_foreign_stores(self) -> None: from neo4j_agent_memory.integrations.strands import Neo4jSessionManager - manager = Neo4jSessionManager( - "s1", memory_client=FakeMemoryClient(), extract_entities=True - ) + manager = Neo4jSessionManager("s1", memory_client=FakeMemoryClient(), extract_entities=True) agent = FakeAgent( memory_manager=MemoryManager(stores=[TestMemoryStore(name="t", extraction=True)]) ) diff --git a/tests/unit/integrations/test_strands_memory_store.py b/tests/unit/integrations/test_strands_memory_store.py index b09f0a1f..4ad12065 100644 --- a/tests/unit/integrations/test_strands_memory_store.py +++ b/tests/unit/integrations/test_strands_memory_store.py @@ -834,8 +834,7 @@ async def test_entity_graph_caps_edges_at_max_edges_on_bolt(self) -> None: centre = Entity(name="Acme Corp", type="ORGANIZATION") client.long_term.entities = [centre] client.long_term.related = [ - (Entity(name=f"Person {i}", type="PERSON"), "WORKS_AT") - for i in range(_MAX_EDGES + 10) + (Entity(name=f"Person {i}", type="PERSON"), "WORKS_AT") for i in range(_MAX_EDGES + 10) ] result = await _entity_graph(client, "Acme Corp", depth=1, nams=False) From 64b561495a4b43c319e8d667302f32ee15f628ba Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 20 Aug 2026 13:08:21 +0200 Subject: [PATCH 30/39] fix(strands): store-tool naming, search budget, tenant-scoped writes, docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from the whole-branch review, other than the loop rebinding and the formatting gate. get_entity_graph reported every bolt edge as an invented `relationship_type` attribute that the real Relationship model does not have (it carries `.type`), and inverted the direction the library reports. Now reads `.type` and orients edges by the relationship's own source_id/target_id. The fake returned an invented relationship shape, so the unit test asserted "WORKS_AT" — unreachable in production: the bolt path cannot report a relationship's own type at all, because execute_read's result.data() flattens a relationship to (start, type, end) and drops its properties. The fake now returns real Relationship objects shaped the way the bolt path really shapes them, the unit test asserts that, and a new integration test pins it against a real Neo4j so a library-side fix surfaces as a failing assertion. The library defect itself is left for a separate change. get_tools' names are now prefixed with the store's name. ToolRegistry skips its duplicate-name check for @tool functions (supports_hot_reload is always true there) and silently overwrites, so the store's get_entity_graph / get_user_preferences were replacing context_graph_tools' identically-named tools — which take different arguments — in the guide's own combined snippet. Prefixing by store name also lets several stores (the documented personal / team / org shape) coexist. search() applied its limit per kind, so max_search_results=5 returned up to 15 rows, entity-first; Strands caps per store and then slices the concatenation, so five entity hits starved preferences and facts entirely. The budget is now handed out round-robin across the enabled kinds, capped at the requested total. add(kind="preference") passes user_identifier, so the write gets its (:User)-[:HAS_PREFERENCE] edge — which is exactly what the store's own get_user_preferences reads — and does not raise under multi_tenant=True. The unit-test CI job installs the `strands` extra. Without it every strands test file's `pytest.importorskip("strands")` skipped, hiding 136 tests including the tripwire for a strands rename of MemoryManager._stores. Verified the extra resolves and the tests run on both 3.10 and 3.13. Docs and spec: user_id scopes writes, not reads; the three spec-mandated store limitations are now in the guide (separate sink, in-process retry dedupe, conversation_id pointed at the chat history); the row-3 pairing exception said "neither side extracts" where the store owns extraction; the spec's "one private attribute" limitation now names both (_stores and _injection_config) and its Testing table no longer promises a NAMS integration suite that was not shipped. Plus: _row() is keyword-only, the get_preferences_for docstring's stated reason for its cast was wrong (it is on the protocol, with a different calling convention), two short_term docstrings named MemorySettings.multi_tenant instead of MemorySettings.memory.multi_tenant, and the CHANGELOG's Fixed entry now states the observable break. --- .github/workflows/ci-python.yml | 7 +- CHANGELOG.md | 10 +- .../how-to/integrations/aws-strands.adoc | 56 ++++++- .../2026-08-19-strands-memory-store-design.md | 23 ++- examples/strands-memory-store/README.md | 9 +- .../integrations/strands/_retrieval.py | 74 +++++++-- .../integrations/strands/_store_tools.py | 52 +++++- .../integrations/strands/memory_store.py | 11 +- src/neo4j_agent_memory/memory/short_term.py | 4 +- .../test_strands_memory_store_integration.py | 36 ++++ tests/unit/integrations/strands_fakes.py | 53 +++++- .../integrations/test_strands_memory_store.py | 156 +++++++++++++++++- 12 files changed, 428 insertions(+), 63 deletions(-) diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml index 13af787b..25beb890 100644 --- a/.github/workflows/ci-python.yml +++ b/.github/workflows/ci-python.yml @@ -95,8 +95,13 @@ jobs: - name: Set up Python ${{ matrix.python-version }} run: uv python install ${{ matrix.python-version }} + # The `strands` extra is needed for the Strands integration's unit tests: + # tests/unit/integrations/test_strands_*.py all begin with + # `pytest.importorskip("strands")`, so without it they silently skip and + # this job reports green on code it never ran. Kept to that one extra -- + # the rest of the suite is designed to run on the dev group alone. - name: Install dependencies - run: uv sync --group dev + run: uv sync --group dev --extra strands - name: Run unit tests run: uv run pytest tests/unit -v --cov=neo4j_agent_memory --cov-report=xml --cov-report=term-missing diff --git a/CHANGELOG.md b/CHANGELOG.md index f8a749ed..ec46ddad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Strands MemoryStore** (`Neo4jMemoryStore`) — cross-session recall for Strands agents via `MemoryManager(stores=[...])`: long-term search, plus writes that feed server-side extraction. Entities only on NAMS. Needs `strands-agents>=1.44.0`. - `get_tools()` adds `get_entity_graph` (multi-hop bolt, 1-hop NAMS) and, bolt-only with a configured user_id, the user-scoped `get_user_preferences`. + `get_tools()` adds `{name}_get_entity_graph` (multi-hop bolt, 1-hop NAMS) and, bolt-only with a configured user_id, the user-scoped `{name}_get_user_preferences`. A store built from `settings=` owns its client and rebinds it when the event loop changes (Strands' synchronous `Agent(...)` runs every call on a fresh loop); a client passed as `client=` is never closed or reconnected, and a loop change raises a named error instead of an opaque driver `RuntimeError`. + Tool names are prefixed with the store's `name` so they coexist with + `context_graph_tools`' identically-named tools rather than silently + replacing them in the agent's registry, and `max_search_results` caps the + *total* rows per `search()` — shared across entities, preferences and facts + so no kind is crowded out. - **Strands SessionManager** (`Neo4jSessionManager`) — automatic conversation persistence/restore for AWS Strands agents via `Agent(session_manager=...)`, backed by any `MemoryClient` (bolt or NAMS). Includes opt-in long-term @@ -62,7 +67,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`add_messages_batch` now accepts `user_identifier`**, enforcing `multi_tenant` and linking the conversation to its `:User`; previously the bulk path silently - wrote unscoped, unlinked conversations. + wrote unscoped, unlinked conversations — so a bulk write that used to succeed + now raises `ValueError` when `multi_tenant=True` and `user_identifier` is omitted. ### Changed diff --git a/docs/modules/ROOT/pages/how-to/integrations/aws-strands.adoc b/docs/modules/ROOT/pages/how-to/integrations/aws-strands.adoc index 9522468b..c8e39cb5 100644 --- a/docs/modules/ROOT/pages/how-to/integrations/aws-strands.adoc +++ b/docs/modules/ROOT/pages/how-to/integrations/aws-strands.adoc @@ -177,7 +177,9 @@ attribute, not a dict key. Pass exactly one of `client` (a pre-connected | `max_search_results` | `None` -| Per-store cap; falls back to the manager's default (3) when unset. +| Cap on the *total* rows one `search()` returns, shared across entities, + preferences and facts so no kind is crowded out. Falls back to the + manager's default (3) when unset. | `writable` | `True` @@ -189,11 +191,15 @@ attribute, not a dict key. Pass exactly one of `client` (a pre-connected | `conversation_id` | minted -| Explicit write-sink override; otherwise a deterministic name is minted in `initialize()`. +| Explicit write-sink override; otherwise a deterministic name is minted in + `initialize()`. Do *not* point it at a chat conversation — see + <<_store_limitations>>. | `user_id` | `None` -| Scopes reads/writes to one tenant; gates the `get_user_preferences` tool. +| Scopes *writes* to one tenant (the `:User` edges on messages and + preferences) and gates the `get_user_preferences` tool. It does *not* + narrow `search()` — the long-term search APIs take no user filter. | `include_entities` / `include_preferences` / `include_facts` | `True` @@ -241,18 +247,20 @@ so no extra model call happens. This is what a `MemoryManager` with === Graph tools `get_tools()` returns graph-native tools the manager itself can't provide, -bound to the store's own client: +bound to the store's own client. Names are prefixed with the store's `name` +so they coexist with the tools factory's (see <<_store_limitations>>) — +a store named `graph` yields `graph_get_entity_graph`: [cols="1,1,1,3"] |=== | Tool | bolt | NAMS | Notes -| `get_entity_graph` +| `{name}_get_entity_graph` | yes | yes | Configurable `depth` on bolt (`get_related_entities`); NAMS traverses one hop only (`expand_graph`, keyed by node id — the entity name is resolved via search first). -| `get_user_preferences` +| `{name}_get_user_preferences` | yes, if `user_id` set | omitted | Bolt-only, and only shipped when the store has a configured `user_id` (scopes the lookup to that tenant). NAMS has no preferences endpoint. @@ -276,7 +284,7 @@ recommended: | Store stays recall-only (`extraction=False`, the default); the session manager persists the transcript and extracts (bolt in place, NAMS server-side). | Store + session manager, with the store also extracting -| Raises at construction — both would write and extract the same turns twice. Exception: bolt with the session manager's own `extract_entities=False` does not raise, since then neither side extracts locally. +| Raises at construction — both would write and extract the same turns twice. Exception: bolt with the session manager's own `extract_entities=False` does not raise, since then only one side extracts — the store. |=== Recommended configuration: @@ -301,6 +309,40 @@ manager's default injection both fold context into the last user message — pairing them injects memory twice. The session manager logs a warning (once) naming the fix; see <<_retrieval_injection>>. +[[_store_limitations]] +=== Limitations + +* **The sink conversation is separate from the readable chat history.** + Paired with `Neo4jSessionManager`, a turn the store ingests exists twice + in the graph: once in the restorable transcript, once in the store's + sink. The duplication is in `Message` nodes, not knowledge — entities + converge through resolution and dedupe. + +* **Retry dedupe is in-process only.** `AddMessagesContext.sequence_numbers` + reset every run, so the `(run_id, sequence_number)` set that skips + already-written turns cannot outlive the process. A restart in the middle + of a retried extraction batch can duplicate messages in the sink. + +* **Pointing `conversation_id` at the chat conversation is unsupported.** + It duplicates `Message` nodes *inside* the readable history, so restored + transcripts gain phantom turns. Not guarded — leave `conversation_id` + unset and let the store mint its own sink. + +* **Owned clients rebind across event loops; borrowed ones do not.** + Strands' synchronous `Agent(...)` runs each call on a fresh event loop, + and both Neo4j transports bind to the loop that opened them. A store built + from `settings=` owns its client and reconnects it when the loop changes + (one reconnect per invocation). A client passed as `client=` is never + closed or reconnected on your behalf: a loop change raises, naming both + remedies. Drive the agent from your own loop, or hand the store + `settings=`. + +* **Tool names are namespaced.** `get_tools()` prefixes each tool with the + store's `name` (`graph_get_entity_graph`), so the store's tools coexist + with `context_graph_tools`' identically-purposed `get_entity_graph` / + `get_user_preferences` — which take different arguments — instead of + silently replacing them in the agent's tool registry. + See `examples/strands-memory-store/` for a runnable demo (`search()`, `add()`, `get_tools()`) that requires no LLM or API key. diff --git a/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md b/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md index e9ab698b..70217d9d 100644 --- a/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md +++ b/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md @@ -127,7 +127,7 @@ protocol requires as store attributes): |---|---|---| | `client` \| `settings` | — | pre-connected `MemoryClient`, or settings the store builds one from | | `conversation_id` | minted in `initialize()` | write sink target | -| `user_id` | `None` | scopes reads in multi-tenant mode | +| `user_id` | `None` | scopes *writes* in multi-tenant mode, and gates the `get_user_preferences` tool. Reads are not narrowed — the long-term search APIs take no user filter | | `include_entities` | `True` | search fan-out | | `include_preferences` | `True` | auto-gated off on NAMS | | `include_facts` | `True` | auto-gated off on NAMS | @@ -141,11 +141,11 @@ Inherited defaults: `writable=True`, `extraction=False`, `max_search_results=Non | Member | Maps to | Notes | |---|---|---| -| `search` | concurrent `search_entities` / `search_preferences` / `search_facts` | reshape of `_retrieval.py` `_retrieve_context`. Per-kind failures isolated and logged; whole-search failures propagate. Limit precedence: `options["max_search_results"]` → `self.max_search_results` → manager default | -| `add` | default: message into the sink with extraction. `metadata["kind"]` ∈ `{preference, fact, entity}` routes to `add_preference` / `add_fact` / `add_entity` | `NotSupportedError` falls back to the sink, logged once. **Not** `long_term.add()`, which makes the whole string an entity *name* of type `OBJECT` (`memory/long_term.py:389-398`) | +| `search` | concurrent `search_entities` / `search_preferences` / `search_facts` | reshape of `_retrieval.py` `_retrieve_context`. Per-kind failures isolated and logged; whole-search failures propagate. Limit precedence: `options["max_search_results"]` → `self.max_search_results` → manager default. The limit caps the *total* rows, handed out round-robin across the enabled kinds: Strands treats it as a per-store cap (`memory_manager.py`) and injection slices the concatenation to the same number, so a per-kind limit would let saturated entity hits crowd preferences and facts out entirely | +| `add` | default: message into the sink with extraction. `metadata["kind"]` ∈ `{preference, fact, entity}` routes to `add_preference` / `add_fact` / `add_entity`, passing `user_identifier` where the primitive takes one (only `add_preference` does) | `NotSupportedError` falls back to the sink, logged once. **Not** `long_term.add()`, which makes the whole string an entity *name* of type `OBJECT` (`memory/long_term.py:389-398`) | | `add_messages` | `bulk_add_messages(sink_id, msgs, extract_entities=True)` | protocol alias forwarding kwargs to `add_messages_batch` (`memory/short_term.py:560-572`) | | `initialize` | connect an owned client; mint the sink when `conversation_id` was omitted | idempotent | -| `get_tools` | `get_entity_graph`, `get_user_preferences` | bound to the store's own client, not the tools factory's cached clients. Excludes search/add, which would collide with `context_graph_tools`' `add_memory` and the manager's own tool of that name | +| `get_tools` | `{name}_get_entity_graph`, `{name}_get_user_preferences` | bound to the store's own client, not the tools factory's cached clients. Excludes search/add, which would collide with `context_graph_tools`' `add_memory` and the manager's own tool of that name. Names carry the store's `name` as a prefix because `ToolRegistry.register_tool` skips its duplicate-name check for `@tool` functions (`supports_hot_reload` is always true there) and silently overwrites — so unprefixed names would replace the factory's identically-named tools, which take different arguments | Tool availability is backend-gated; the differing depth is stated in the tool description the model sees: @@ -214,6 +214,15 @@ Durability stays where Strands puts it: background extraction plus As the session manager: a store built from `settings` owns its client and closes it via `aclose()` / async context manager; a store handed a live `MemoryClient` never closes it. + +Both transports bind to the event loop that opened them, and Strands' synchronous +entry points run every call on a fresh loop (`strands._async.run_async` is +`asyncio.run` in a worker thread), so `Agent.__init__` initializes the store on one +loop and each `Agent.__call__` drives it from another. `initialize()` therefore +records the running loop: on a change it closes and reconnects an **owned** client, +and raises a named error for a **borrowed** one — reconnecting someone else's client +is not the store's call, and the alternative is an opaque `RuntimeError` from inside +the driver. The existing warning against sharing one client between the tools factory and the session manager extends to the store. @@ -314,7 +323,7 @@ gets no guard. | Layer | Coverage | |---|---| | Python unit | `search` fan-out with per-kind isolation; limit precedence; `MemoryEntry` metadata; `add` kind routing incl. `NotSupportedError` fallback; `add_messages` dedupe across a retried batch; sink minting idempotence; `get_tools`; client ownership; both guards | -| Python integration | bolt via docker-compose; NAMS gated on a key, skipped without | +| Python integration | bolt via docker-compose. A key-gated NAMS suite is **not yet implemented** | | TS unit | mirrored test names against the existing msw/bridge setup, in `test/unit/strands/memory-store.test.ts` | | Guards | extend `tests/unit/integrations/strands_fakes.py` with a fake agent exposing `memory_manager`; assert the private-attribute read fails loudly if strands moves it | | Examples | `tests/examples/test_no_phantom_methods.py` covers the new example automatically | @@ -371,5 +380,7 @@ against `strands-agents/harness-sdk`, needing explicit approval. - A store paired with the session manager writes turns to a sink separate from the readable history, so turn text exists twice in the graph. Entities converge via resolution/dedupe; the duplication is in messages, not knowledge. -- Guard 1 reads one private strands attribute (`MemoryManager._stores`). +- The guards read two private strands attributes: `MemoryManager._stores` (guard 1) + and `MemoryManager._injection_config` (guard 2). Both reads are in one file and + pinned by a test that fails loudly on a strands rename. - No TS guard when `Neo4jSessionStorage` is used without `Neo4jConversationManager`. diff --git a/examples/strands-memory-store/README.md b/examples/strands-memory-store/README.md index 5afd496a..543e5d9f 100644 --- a/examples/strands-memory-store/README.md +++ b/examples/strands-memory-store/README.md @@ -20,9 +20,10 @@ - **`add()`** — default sink writes a message with extraction on; `metadata["kind"]` (`"preference"` / `"fact"` / `"entity"`) routes to a typed write instead. -- **`get_tools()`** — graph-native tools (`get_entity_graph`, and, bolt-only - with a configured `user_id`, `get_user_preferences`) that a `MemoryManager` - cannot provide on its own. +- **`get_tools()`** — graph-native tools that a `MemoryManager` cannot provide + on its own: `{name}_get_entity_graph`, and — bolt-only, with a configured + `user_id` — `{name}_get_user_preferences`. The store's `name` prefixes them + so they coexist with `context_graph_tools`' identically-named tools. `Neo4jSessionManager` (`examples/strands-session-manager/`) remains for transcript persistence — the session manager restores sessions, the memory @@ -63,7 +64,7 @@ search('what does the user prefer?'): entity: [entity] Acme Corp (ORGANIZATION) preference: [preference] ui: Prefers dark mode add(...): {'kind': 'message', 'id': '...'} -tools: ['get_entity_graph', 'get_user_preferences'] +tools: ['graph_get_entity_graph', 'graph_get_user_preferences'] ``` ## With a real agent diff --git a/src/neo4j_agent_memory/integrations/strands/_retrieval.py b/src/neo4j_agent_memory/integrations/strands/_retrieval.py index cec675be..18311754 100644 --- a/src/neo4j_agent_memory/integrations/strands/_retrieval.py +++ b/src/neo4j_agent_memory/integrations/strands/_retrieval.py @@ -100,8 +100,15 @@ class _EntryRow: def _row( - kind: str, entry_id: Any, entry_type: str, source_metadata: dict[str, Any] | None, content: str + *, + kind: str, + entry_id: Any, + entry_type: str, + source_metadata: dict[str, Any] | None, + content: str, ) -> _EntryRow: + """Build one row. Keyword-only: three of five params are plain strings, + so a transposition would otherwise type-check silently.""" metadata: dict[str, Any] = {"kind": kind, "id": str(entry_id), "type": entry_type} score = (source_metadata or {}).get("similarity") if score is not None: @@ -114,26 +121,32 @@ def _row( def _entity_row(entity: Entity) -> _EntryRow: return _row( - "entity", - entity.id, - entity.full_type or entity.type, - entity.metadata, - _format_entity(entity), + kind="entity", + entry_id=entity.id, + entry_type=entity.full_type or entity.type, + source_metadata=entity.metadata, + content=_format_entity(entity), ) def _preference_row(preference: Preference) -> _EntryRow: return _row( - "preference", - preference.id, - preference.category, - preference.metadata, - _format_preference(preference), + kind="preference", + entry_id=preference.id, + entry_type=preference.category, + source_metadata=preference.metadata, + content=_format_preference(preference), ) def _fact_row(fact: Fact) -> _EntryRow: - return _row("fact", fact.id, fact.predicate, fact.metadata, _format_fact(fact)) + return _row( + kind="fact", + entry_id=fact.id, + entry_type=fact.predicate, + source_metadata=fact.metadata, + content=_format_fact(fact), + ) async def _retrieve_entries( @@ -149,6 +162,15 @@ async def _retrieve_entries( ) -> list[_EntryRow]: """Sibling of ``_retrieve_context``: same fan-out, rows instead of a string. + ``limit`` caps the *total* rows returned, not each kind: Strands treats a + store's result count as a per-store cap (``MemoryManager.search``) and + injection then slices the concatenation to the same number, so returning + ``limit`` rows per kind would let a saturated entity search crowd + preferences and facts out of the model's context entirely. The budget is + handed out round-robin, so every enabled kind with a hit is represented + before any kind takes a second row, and unused capacity flows to whoever + has more hits. + Per-kind failures are logged and skipped so one dead index doesn't lose the others' hits. NAMS has no preference/fact search endpoints, so those are skipped rather than raised on every call. @@ -170,10 +192,34 @@ async def _retrieve_entries( *(search(query, limit=limit, threshold=min_score) for _, search, _ in active), return_exceptions=True, ) - rows: list[_EntryRow] = [] + per_kind: list[list[_EntryRow]] = [] for (kind, _, to_row), result in zip(active, results): if isinstance(result, BaseException): logger.warning("Long-term %s search failed: %s", kind, result) + per_kind.append([]) continue - rows.extend(to_row(item) for item in result) + per_kind.append([to_row(item) for item in result]) + + rows: list[_EntryRow] = [] + for share, hits in zip(_share_budget([len(hits) for hits in per_kind], limit), per_kind): + rows.extend(hits[:share]) return rows + + +def _share_budget(counts: list[int], limit: int) -> list[int]: + """Split ``limit`` rows across kinds, round-robin, so none is starved. + + One row to each kind that still has hits, then a second to each, and so + on until the budget or the hits run out — which also means a kind with + more hits absorbs whatever the others leave unused. + """ + take = [0] * len(counts) + remaining = min(limit, sum(counts)) + while remaining > 0: + for index, available in enumerate(counts): + if remaining == 0: + break + if take[index] < available: + take[index] += 1 + remaining -= 1 + return take diff --git a/src/neo4j_agent_memory/integrations/strands/_store_tools.py b/src/neo4j_agent_memory/integrations/strands/_store_tools.py index 0fae4fd5..46afa398 100644 --- a/src/neo4j_agent_memory/integrations/strands/_store_tools.py +++ b/src/neo4j_agent_memory/integrations/strands/_store_tools.py @@ -61,11 +61,23 @@ async def _entity_graph( edges: list[dict[str, str]] = [] for other, relationship in related[:_MAX_EDGES]: nodes.append({"name": other.display_name, "type": other.type, "is_center": False}) + # Report the orientation the library reports rather than inventing one: + # GET_ENTITY_RELATIONSHIPS matches undirected and get_related_entities + # sets source_id to the centre for every hit, so these ids are the only + # direction available. Likewise `type` is what the library resolved -- + # today always "RELATED_TO", because execute_read's result.data() + # flattens a relationship to (start, type, end) and drops its + # properties, so the property-level type never survives the round trip. + # A library-side fix would flow through here unchanged. + names = { + str(centre.id): centre.display_name, + str(other.id): other.display_name, + } edges.append( { - "from": other.display_name, - "relationship": getattr(relationship, "relationship_type", "RELATED_TO"), - "to": centre.display_name, + "from": names.get(str(relationship.source_id), centre.display_name), + "relationship": relationship.type, + "to": names.get(str(relationship.target_id), other.display_name), } ) return {"center": centre.display_name, "depth": depth, "nodes": nodes, "edges": edges} @@ -79,8 +91,9 @@ async def _user_preferences( ``get_preferences_for`` is user-scoped and needs no embedder, unlike ``search_preferences`` (which returns ``[]`` with no embedder and no ``:User`` filter at all -- both a silent-empty and a cross-tenant-leak - risk). It is not on ``LongTermProtocol`` either, so cast to the concrete - class. + risk). It *is* on ``LongTermProtocol`` (``core/protocols.py``), but + declared with a keyword-only ``user_identifier`` against the concrete + class's positional one, so the cast is still required. """ bolt_long_term = cast("LongTermMemory", client.long_term) preferences = await bolt_long_term.get_preferences_for(user_id, active_only=True) @@ -92,14 +105,37 @@ async def _user_preferences( ] +def _tool_prefix(name: str) -> str: + """The store's name, reduced to something legal in a tool name. + + Tool names are namespaced per store so they can coexist both with + ``context_graph_tools``' identically-named tools and with a second + store's (``dataclasses.replace(config, name="team")`` is the documented + way to run personal / team / org stores side by side). + """ + slug = "".join(char if char.isalnum() else "_" for char in name.lower()).strip("_") + while "__" in slug: + slug = slug.replace("__", "_") + return slug or "store" + + def build_store_tools(store: Neo4jMemoryStore) -> list[AgentTool]: - """Build the store's graph tools, gated by what the backend exposes.""" + """Build the store's graph tools, gated by what the backend exposes. + + Names are prefixed with the store's own name. ``ToolRegistry`` silently + *overwrites* a duplicate name for ``@tool`` functions (its duplicate + check is skipped whenever ``supports_hot_reload`` is true, which it + always is for decorated functions), so unprefixed ``get_entity_graph`` + / ``get_user_preferences`` would replace the factory's tools of those + names -- which take different arguments -- with no warning. + """ from strands import tool client = store._client nams = store.is_nams + prefix = _tool_prefix(store.name) - @tool + @tool(name=f"{prefix}_get_entity_graph") async def get_entity_graph(entity_name: str, depth: int = 2) -> dict[str, Any]: """Explore the graph neighbourhood of an entity. @@ -124,7 +160,7 @@ async def get_entity_graph(entity_name: str, depth: int = 2) -> dict[str, Any]: if not nams and store.user_id: user_id = store.user_id - @tool + @tool(name=f"{prefix}_get_user_preferences") async def get_user_preferences(category: str | None = None, limit: int = 20) -> Any: """Retrieve the configured user's preferences, optionally filtered by category. diff --git a/src/neo4j_agent_memory/integrations/strands/memory_store.py b/src/neo4j_agent_memory/integrations/strands/memory_store.py index 8bedf52e..c15b95a9 100644 --- a/src/neo4j_agent_memory/integrations/strands/memory_store.py +++ b/src/neo4j_agent_memory/integrations/strands/memory_store.py @@ -355,7 +355,16 @@ async def add(self, content: str, metadata: dict[str, Any] | None = None) -> dic async def _add_typed(self, kind: str, content: str, meta: dict[str, Any]) -> dict[str, Any]: long_term = self._client.long_term if kind == "preference": - preference = await long_term.add_preference(meta.get("category", "memory"), content) + # user_identifier as everywhere else in this class: without it + # multi_tenant=True raises ValueError (not NotSupportedError, so + # no sink fallback), and the preference gets no + # (:User)-[:HAS_PREFERENCE] edge -- which is exactly what the + # store's own get_user_preferences tool reads. + preference = await long_term.add_preference( + meta.get("category", "memory"), + content, + user_identifier=self.user_id, + ) return {"kind": "preference", "id": str(preference.id)} if kind == "fact": subject = meta.get("subject") diff --git a/src/neo4j_agent_memory/memory/short_term.py b/src/neo4j_agent_memory/memory/short_term.py index 941deb5b..5f5c4a93 100644 --- a/src/neo4j_agent_memory/memory/short_term.py +++ b/src/neo4j_agent_memory/memory/short_term.py @@ -401,7 +401,7 @@ async def add_messages_batch( on_batch_complete: Callback after each batch completes (batch_num, batch_messages) user_identifier: When provided, scopes the conversation to a :User node via ``(:User)-[:HAS_CONVERSATION]->(:Conversation)``. - Required when ``MemorySettings.multi_tenant=True``. + Required when ``MemorySettings.memory.multi_tenant=True``. Returns: List of created Message objects @@ -703,7 +703,7 @@ async def add_message( describing the entities to MERGE on and link. user_identifier: When provided, scopes the conversation to a :User node via ``(:User)-[:HAS_CONVERSATION]->(:Conversation)``. - Required when ``MemorySettings.multi_tenant=True``. + Required when ``MemorySettings.memory.multi_tenant=True``. Returns: The created message diff --git a/tests/integration/test_strands_memory_store_integration.py b/tests/integration/test_strands_memory_store_integration.py index 961ed65c..8c5416d7 100644 --- a/tests/integration/test_strands_memory_store_integration.py +++ b/tests/integration/test_strands_memory_store_integration.py @@ -180,3 +180,39 @@ async def real_work() -> list[dict[str, object]]: assert store._loop is not connecting_loop, "the store rebound to the new loop" finally: run_async(store.aclose) + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_entity_graph_reports_what_the_bolt_stack_can_actually_report( + clean_memory_client, +) -> None: + """Pins the edge payload against a real Neo4j — and a library defect under it. + + ``Neo4jClient.execute_read`` returns ``result.data()``, which renders a + relationship as ``(start_props, type, end_props)`` and drops its + properties entirely. ``LongTermMemory.get_related_entities`` therefore + falls through to ``type="RELATED_TO"`` for every hit even though + ``CREATE_ENTITY_RELATIONSHIP`` stored ``r.type = "WORKS_AT"``, and it + hardcodes ``source_id`` to the centre because + ``GET_ENTITY_RELATIONSHIPS`` matches undirected. + + That defect is the library's, not the store's, and is deliberately left + for a separate change; this test pins today's behaviour so a fix shows + up here (and in ``strands_fakes.FakeLongTerm.get_related_entities``, + which mirrors it) rather than silently changing what the tool tells the + model. + """ + from neo4j_agent_memory.integrations.strands._store_tools import _entity_graph + + long_term = clean_memory_client.long_term + acme, _ = await long_term.add_entity("Acme Corp", "ORGANIZATION", deduplicate=False) + ada, _ = await long_term.add_entity("Ada Lovelace", "PERSON", deduplicate=False) + await long_term.add_relationship(ada, acme, "WORKS_AT") + + result = await _entity_graph(clean_memory_client, "Acme Corp", depth=2, nams=False) + + assert result["center"] == "Acme Corp" + assert result["edges"] == [ + {"from": "Acme Corp", "relationship": "RELATED_TO", "to": "Ada Lovelace"} + ] diff --git a/tests/unit/integrations/strands_fakes.py b/tests/unit/integrations/strands_fakes.py index 000dacb6..b075d9ee 100644 --- a/tests/unit/integrations/strands_fakes.py +++ b/tests/unit/integrations/strands_fakes.py @@ -140,12 +140,19 @@ def __init__(self) -> None: self.added_facts: list[tuple[str, str, str]] = [] self.added_entities: list[tuple[str, str]] = [] self.nams_mode = False - self.related: list[tuple[Any, str]] = [] + self.related: list[Any] = [] self.related_kwargs: list[dict[str, Any]] = [] self.expansion: dict[str, list[dict[str, Any]]] = {"nodes": [], "edges": []} self.expand_calls: list[str] = [] self.preferences_for: list[Any] = [] self.preferences_for_calls: list[dict[str, Any]] = [] + #: Preferences that got a ``(:User)-[:HAS_PREFERENCE]`` edge, keyed by + #: user -- the only ones ``get_preferences_for`` can see. + self.preferences_by_user: dict[str, list[Any]] = {} + #: Mirrors ``MemorySettings.memory.multi_tenant``: bolt's + #: ``_enforce_multi_tenant`` raises ``ValueError`` (not + #: ``NotSupportedError``) when it is on and no identifier is passed. + self.multi_tenant = False async def _maybe_fail(self) -> None: if self.fail_searches: @@ -185,12 +192,24 @@ async def search_facts(self, query: str, **kwargs: Any) -> list[Any]: await self._maybe_fail() return self.facts - async def add_preference(self, category: str, preference: str, **kwargs: Any) -> Any: + async def add_preference( + self, category: str, preference: str, *, user_identifier: str | None = None, **kwargs: Any + ) -> Any: self._reject_on_nams("add_preference") + if self.multi_tenant and user_identifier is None: + raise ValueError( + "MemorySettings.memory.multi_tenant=True but no user_identifier was supplied." + ) self.added_preferences.append((category, preference)) from neo4j_agent_memory.memory.long_term import Preference - return Preference(category=category, preference=preference) + stored = Preference(category=category, preference=preference) + # Bolt writes the (:User)-[:HAS_PREFERENCE] edge only when + # user_identifier is given, and get_preferences_for reads exactly + # that edge -- so an unscoped write is invisible to it. + if user_identifier is not None: + self.preferences_by_user.setdefault(user_identifier, []).append(stored) + return stored async def add_fact(self, subject: str, predicate: str, obj: str, **kwargs: Any) -> Any: self._reject_on_nams("add_fact") @@ -211,14 +230,30 @@ async def add_entity(self, name: str, entity_type: str, **kwargs: Any) -> Any: return entity, None async def get_related_entities(self, entity: Any, **kwargs: Any) -> list[tuple[Any, Any]]: + """Real ``Relationship`` objects, shaped as the bolt path really shapes them. + + The bolt implementation cannot report a relationship's own type or + direction: ``Neo4jClient.execute_read`` returns ``result.data()``, + which renders a relationship as ``(start_props, type, end_props)`` and + drops its properties, so ``memory/long_term.py``'s parse falls through + to ``type="RELATED_TO"`` for every hit, with ``source_id`` hardcoded + to the centre. Verified live against Neo4j 5 (see + ``tests/integration/test_strands_memory_store_integration.py``). This + fake reproduces that rather than inventing a richer relationship the + production stack never returns. + """ self._reject_on_nams("get_related_entities") self.related_kwargs.append(kwargs) + from neo4j_agent_memory.memory.long_term import Relationship - class _Rel: - def __init__(self, rel_type: str) -> None: - self.relationship_type = rel_type - - return [(other, _Rel(rel_type)) for other, rel_type in self.related] + centre_id = getattr(entity, "id", entity) + return [ + ( + other, + Relationship(source_id=centre_id, target_id=other.id, type="RELATED_TO"), + ) + for other in self.related + ] async def expand_graph(self, node_id: str, **kwargs: Any) -> dict[str, list[dict[str, Any]]]: self.expand_calls.append(str(node_id)) @@ -227,7 +262,7 @@ async def expand_graph(self, node_id: str, **kwargs: Any) -> dict[str, list[dict async def get_preferences_for(self, user_identifier: str, **kwargs: Any) -> list[Any]: self._reject_on_nams("get_preferences_for") self.preferences_for_calls.append({"user_identifier": user_identifier, **kwargs}) - return self.preferences_for + return [*self.preferences_for, *self.preferences_by_user.get(user_identifier, [])] class FakeReasoning: diff --git a/tests/unit/integrations/test_strands_memory_store.py b/tests/unit/integrations/test_strands_memory_store.py index 4ad12065..868a936e 100644 --- a/tests/unit/integrations/test_strands_memory_store.py +++ b/tests/unit/integrations/test_strands_memory_store.py @@ -395,6 +395,47 @@ async def test_limit_precedence_call_then_store_then_default(self) -> None: await store.search("q", {"max_search_results": 0}) assert client.long_term.search_kwargs[-1]["limit"] == 0 + @pytest.mark.asyncio + async def test_the_limit_caps_the_total_and_no_kind_is_starved(self) -> None: + """Entities alone could fill the limit; a preference must still get through. + + Strands treats a store's row count as the per-store cap and then + slices the concatenation to the same number, so returning ``limit`` + rows *per kind* meant five entity hits pushed every preference and + fact out of the injected block — starving exactly what a POLE graph + is for. + """ + from neo4j_agent_memory.memory.long_term import Entity, Fact, Preference + + client = FakeMemoryClient() + client.long_term.entities = [Entity(name=f"E{i}", type="OBJECT") for i in range(10)] + client.long_term.preferences = [Preference(category="ui", preference="dark mode")] + client.long_term.facts = [Fact(subject="Ada", predicate="uses", object="Python")] + + store = _store(name="graph", client=client, max_search_results=5) + await store.initialize() + entries = await store.search("q") + + kinds = [e.metadata["kind"] for e in entries if e.metadata] + assert len(entries) == 5, "the limit caps the total, not each kind" + assert kinds.count("preference") == 1 + assert kinds.count("fact") == 1 + assert kinds.count("entity") == 3, "unused capacity flows to the kind with more hits" + + @pytest.mark.asyncio + async def test_a_lone_kind_may_use_the_whole_budget(self) -> None: + """Round-robin must not reserve capacity for kinds that returned nothing.""" + from neo4j_agent_memory.memory.long_term import Entity + + client = FakeMemoryClient() + client.long_term.entities = [Entity(name=f"E{i}", type="OBJECT") for i in range(10)] + + store = _store(name="graph", client=client, max_search_results=4) + await store.initialize() + entries = await store.search("q") + + assert len(entries) == 4 + @pytest.mark.asyncio async def test_kind_flags_are_honoured(self) -> None: """All three kinds have data; only the enabled one should ever be searched.""" @@ -768,21 +809,21 @@ def test_bolt_exposes_both_graph_tools(self) -> None: store = _store(name="graph", client=FakeMemoryClient(), user_id="alice") names = {t.tool_name for t in store.get_tools()} - assert names == {"get_entity_graph", "get_user_preferences"} + assert names == {"graph_get_entity_graph", "graph_get_user_preferences"} def test_bolt_omits_preferences_tool_without_a_user_id(self) -> None: """get_preferences_for requires a user identifier; with none, no tool.""" store = _store(name="graph", client=FakeMemoryClient()) names = {t.tool_name for t in store.get_tools()} - assert names == {"get_entity_graph"} + assert names == {"graph_get_entity_graph"} def test_nams_omits_the_preferences_tool(self) -> None: """NAMS exposes no preferences endpoint; expand_graph covers traversal.""" store = _store(name="graph", client=FakeMemoryClient(nams_mode=True), user_id="alice") names = {t.tool_name for t in store.get_tools()} - assert names == {"get_entity_graph"} + assert names == {"graph_get_entity_graph"} def test_graph_tools_false_exposes_nothing(self) -> None: store = _store(name="graph", client=FakeMemoryClient(), graph_tools=False) @@ -797,13 +838,20 @@ async def test_entity_graph_traverses_with_depth_on_bolt(self) -> None: client = FakeMemoryClient() centre = Entity(name="Acme Corp", type="ORGANIZATION") client.long_term.entities = [centre] - client.long_term.related = [(Entity(name="Ada", type="PERSON"), "WORKS_AT")] + client.long_term.related = [Entity(name="Ada", type="PERSON")] result = await _entity_graph(client, "Acme Corp", depth=2, nams=False) assert result["center"] == "Acme Corp" assert {"name": "Ada", "type": "PERSON", "is_center": False} in result["nodes"] - assert {"from": "Ada", "relationship": "WORKS_AT", "to": "Acme Corp"} in result["edges"] + # "RELATED_TO", centre-as-source: the only thing the bolt stack can + # report today. get_related_entities reads relationship properties out + # of execute_read's result.data(), which flattens a relationship to + # (start, type, end) and loses them, so the property-level type never + # survives and source_id is hardcoded to the centre. Pre-existing + # library defect, pinned here and in the integration suite so a fix + # shows up as a failing assertion rather than going unnoticed. + assert {"from": "Acme Corp", "relationship": "RELATED_TO", "to": "Ada"} in result["edges"] assert client.long_term.related_kwargs[-1]["depth"] == 2 @pytest.mark.asyncio @@ -818,7 +866,7 @@ async def test_entity_graph_depth_is_clamped_to_three_by_the_tool(self) -> None: store = _store(name="graph", client=client, user_id="alice") tools = {t.tool_name: t for t in store.get_tools()} - await tools["get_entity_graph"](entity_name="Acme Corp", depth=99) + await tools["graph_get_entity_graph"](entity_name="Acme Corp", depth=99) assert client.long_term.related_kwargs[-1]["depth"] == 3 @@ -834,7 +882,7 @@ async def test_entity_graph_caps_edges_at_max_edges_on_bolt(self) -> None: centre = Entity(name="Acme Corp", type="ORGANIZATION") client.long_term.entities = [centre] client.long_term.related = [ - (Entity(name=f"Person {i}", type="PERSON"), "WORKS_AT") for i in range(_MAX_EDGES + 10) + Entity(name=f"Person {i}", type="PERSON") for i in range(_MAX_EDGES + 10) ] result = await _entity_graph(client, "Acme Corp", depth=1, nams=False) @@ -880,7 +928,7 @@ async def test_get_user_preferences_forwards_the_stores_user_id(self) -> None: store = _store(name="graph", client=client, user_id="alice") tools = {t.tool_name: t for t in store.get_tools()} - await tools["get_user_preferences"]() + await tools["graph_get_user_preferences"]() assert client.long_term.preferences_for_calls[-1]["user_identifier"] == "alice" @@ -896,6 +944,96 @@ async def test_get_user_preferences_category_filter_narrows_results(self) -> Non store = _store(name="graph", client=client, user_id="alice") tools = {t.tool_name: t for t in store.get_tools()} - result = await tools["get_user_preferences"](category="food") + result = await tools["graph_get_user_preferences"](category="food") assert result == [{"category": "food", "preference": "loves sushi", "context": None}] + + +class TestToolNamespacing: + """The store's tools must not silently replace the factory's. + + ``ToolRegistry.register_tool`` raises on a duplicate name only when the + tool does not support hot reload — and ``@tool``-decorated functions + always do, so both sides' tools take the same code path into + ``registry[name] = tool`` and the later registration wins, with no + warning. Both sides ship ``get_entity_graph`` and + ``get_user_preferences``, and they take different arguments. + """ + + def test_names_are_prefixed_with_the_store_name(self) -> None: + store = _store(name="graph", client=FakeMemoryClient(), user_id="alice") + + names = {t.tool_name for t in store.get_tools()} + + assert names == {"graph_get_entity_graph", "graph_get_user_preferences"} + + def test_no_name_collides_with_the_tools_factory(self) -> None: + from neo4j_agent_memory.integrations.strands import context_graph_tools + + store = _store(name="graph", client=FakeMemoryClient(), user_id="alice") + factory_names = { + t.tool_name + for t in context_graph_tools(neo4j_uri="bolt://localhost:7687", neo4j_password="p") + } + store_names = {t.tool_name for t in store.get_tools()} + + assert factory_names & store_names == set() + # And the names the collision was about are the factory's alone. + assert {"get_entity_graph", "get_user_preferences"} <= factory_names + + def test_two_stores_on_one_manager_get_distinct_names(self) -> None: + """`dataclasses.replace(config, name="team")` is the documented multi-store shape.""" + client = FakeMemoryClient() + personal = _store(name="personal", client=client, user_id="alice") + team = _store(name="team", client=client, user_id="alice") + + personal_names = {t.tool_name for t in personal.get_tools()} + team_names = {t.tool_name for t in team.get_tools()} + + assert personal_names & team_names == set() + + def test_a_name_needing_sanitising_still_yields_a_legal_prefix(self) -> None: + store = _store(name="Team / Graph!", client=FakeMemoryClient()) + + names = {t.tool_name for t in store.get_tools()} + + assert names == {"team_graph_get_entity_graph"} + + +class TestPreferenceRoundTrip: + @pytest.mark.asyncio + async def test_a_written_preference_is_readable_by_the_stores_own_tool(self) -> None: + """``add(kind="preference")`` must be visible to ``get_user_preferences``. + + Both are user-scoped: the write needs ``user_identifier`` for the + ``(:User)-[:HAS_PREFERENCE]`` edge, and ``get_preferences_for`` reads + precisely that edge. Without it the store wrote something its own + tool could never return. + """ + client = FakeMemoryClient() + store = _store(name="graph", client=client, user_id="alice") + await store.initialize() + + await store.add("Prefers dark mode", {"kind": "preference", "category": "ui"}) + + tools = {t.tool_name: t for t in store.get_tools()} + result = await tools["graph_get_user_preferences"]() + + assert [p["preference"] for p in result] == ["Prefers dark mode"] + + @pytest.mark.asyncio + async def test_a_preference_write_is_tenant_scoped(self) -> None: + """Under multi_tenant=True an unscoped write raises ValueError, not NotSupportedError. + + ValueError is not caught by ``add``'s ``NotSupportedError`` fallback, + so it would have surfaced as a hard failure rather than a sink write. + """ + client = FakeMemoryClient() + client.long_term.multi_tenant = True + store = _store(name="graph", client=client, user_id="alice") + await store.initialize() + + await store.add("Prefers dark mode", {"kind": "preference"}) + + assert client.long_term.added_preferences == [("memory", "Prefers dark mode")] + assert client.short_term.add_message_calls == [], "no fallback to the sink" From 55b52541602006803d63ce7d066951ed82cd7150 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 20 Aug 2026 14:44:48 +0200 Subject: [PATCH 31/39] fix(strands): drop unneeded cast, clarify tool typing, simplify sink resolution, rename _loop - _store_tools.py: get_preferences_for is on LongTermProtocol with a keyword-only user_identifier; call it that way and drop the now-unneeded cast (and the stale docstring claiming otherwise). Annotate the two @tool-built objects explicitly as AgentTool via a typed local, which is IDE-robust against decorator-overload inference gaps without a cast/ignore. - memory_store.py: _resolve_sink now has a single cache-and-return exit; the NAMS list-match-else-create branch moves into _resolve_nams_sink(). Behaviour unchanged (bolt: no backend call; NAMS: list/match/create). - memory_store.py: rename _loop to _connected_loop with a comment noting it is only ever compared, never awaited on; update the two integration test reads of the private attribute to match. --- .../integrations/strands/_store_tools.py | 26 +++++++++++------ .../integrations/strands/memory_store.py | 29 +++++++++---------- .../test_strands_memory_store_integration.py | 4 +-- 3 files changed, 33 insertions(+), 26 deletions(-) diff --git a/src/neo4j_agent_memory/integrations/strands/_store_tools.py b/src/neo4j_agent_memory/integrations/strands/_store_tools.py index 46afa398..3d8a7e58 100644 --- a/src/neo4j_agent_memory/integrations/strands/_store_tools.py +++ b/src/neo4j_agent_memory/integrations/strands/_store_tools.py @@ -91,12 +91,12 @@ async def _user_preferences( ``get_preferences_for`` is user-scoped and needs no embedder, unlike ``search_preferences`` (which returns ``[]`` with no embedder and no ``:User`` filter at all -- both a silent-empty and a cross-tenant-leak - risk). It *is* on ``LongTermProtocol`` (``core/protocols.py``), but - declared with a keyword-only ``user_identifier`` against the concrete - class's positional one, so the cast is still required. + risk). It *is* on ``LongTermProtocol`` (``core/protocols.py``), keyword-only + ``user_identifier`` and all, so no cast is needed here. """ - bolt_long_term = cast("LongTermMemory", client.long_term) - preferences = await bolt_long_term.get_preferences_for(user_id, active_only=True) + preferences = await client.long_term.get_preferences_for( + user_identifier=user_id, active_only=True + ) if category: preferences = [p for p in preferences if p.category.lower() == category.lower()] return [ @@ -135,8 +135,7 @@ def build_store_tools(store: Neo4jMemoryStore) -> list[AgentTool]: nams = store.is_nams prefix = _tool_prefix(store.name) - @tool(name=f"{prefix}_get_entity_graph") - async def get_entity_graph(entity_name: str, depth: int = 2) -> dict[str, Any]: + async def _get_entity_graph(entity_name: str, depth: int = 2) -> dict[str, Any]: """Explore the graph neighbourhood of an entity. Use this to find how an entity connects to others — who works where, @@ -149,6 +148,13 @@ async def get_entity_graph(entity_name: str, depth: int = 2) -> dict[str, Any]: """ return await _entity_graph(client, entity_name, depth=max(1, min(depth, 3)), nams=nams) + # Annotated explicitly as AgentTool: @tool's overloads resolve this correctly + # under mypy --strict and ty, but some IDEs' inference falls back to the + # undecorated function's callable type instead of the decorator's declared + # return type. The explicit annotation on the assignment target sidesteps + # that inference gap without a cast/ignore/Any. + get_entity_graph: AgentTool = tool(name=f"{prefix}_get_entity_graph")(_get_entity_graph) + tools: list[AgentTool] = [get_entity_graph] # get_preferences_for requires a user identifier and is bolt-only (NAMS @@ -160,8 +166,7 @@ async def get_entity_graph(entity_name: str, depth: int = 2) -> dict[str, Any]: if not nams and store.user_id: user_id = store.user_id - @tool(name=f"{prefix}_get_user_preferences") - async def get_user_preferences(category: str | None = None, limit: int = 20) -> Any: + async def _get_user_preferences(category: str | None = None, limit: int = 20) -> Any: """Retrieve the configured user's preferences, optionally filtered by category. Returns only preferences belonging to this store's configured @@ -173,6 +178,9 @@ async def get_user_preferences(category: str | None = None, limit: int = 20) -> """ return await _user_preferences(client, user_id, category, limit=limit) + get_user_preferences: AgentTool = tool(name=f"{prefix}_get_user_preferences")( + _get_user_preferences + ) tools.append(get_user_preferences) return tools diff --git a/src/neo4j_agent_memory/integrations/strands/memory_store.py b/src/neo4j_agent_memory/integrations/strands/memory_store.py index c15b95a9..74d8a3fe 100644 --- a/src/neo4j_agent_memory/integrations/strands/memory_store.py +++ b/src/neo4j_agent_memory/integrations/strands/memory_store.py @@ -147,7 +147,9 @@ def __init__(self, config: Neo4jMemoryStoreConfig) -> None: self._run_id = uuid.uuid4().hex self._written: set[tuple[str, int]] = set() self._initialized = False - self._loop: asyncio.AbstractEventLoop | None = None + # The loop we last connected on -- only ever compared against the + # current running loop, never awaited on (see `initialize()`). + self._connected_loop: asyncio.AbstractEventLoop | None = None self._warned_unsupported_kinds: set[str] = set() if config.client is not None: @@ -206,29 +208,26 @@ async def _resolve_sink(self) -> str: if self._sink_key is not None: return self._sink_key - if not self.is_nams: - sink_key = self._sink_name - self._sink_key = sink_key - return sink_key + key = self._sink_name if not self.is_nams else await self._resolve_nams_sink() + self._sink_key = key + return key + async def _resolve_nams_sink(self) -> str: + """List NAMS conversations, matching this store's ``_STORE_KEY`` metadata, else create one.""" short_term = self._client.short_term conversations = await short_term.list_conversations( user_identifier=self.user_id, limit=1000 ) for conversation in conversations: if (conversation.metadata or {}).get(_STORE_KEY) == self._sink_name: - sink_key = str(conversation.id) - self._sink_key = sink_key - return sink_key + return str(conversation.id) created = await short_term.create_conversation( session_id=self._sink_name, metadata={_STORE_KEY: self._sink_name, "session_type": "MEMORY_STORE"}, user_identifier=self.user_id, ) - sink_key = str(created.id) - self._sink_key = sink_key - return sink_key + return str(created.id) async def initialize(self) -> None: """Connect the client, rebinding it if the event loop changed. @@ -254,7 +253,7 @@ async def initialize(self) -> None: loop = asyncio.get_running_loop() if self._initialized: - if self._loop is loop: + if self._connected_loop is loop: return if not self._owns_client: raise RuntimeError( @@ -280,7 +279,7 @@ async def initialize(self) -> None: elif not self._client.is_connected: await self._client.connect() - self._loop = loop + self._connected_loop = loop self._initialized = True async def search(self, query: str, options: SearchOptions | None = None) -> list[MemoryEntry]: @@ -472,7 +471,7 @@ async def aclose(self) -> None: stale ``_initialized``. """ if self._owns_client: - stale = self._loop is not None and self._loop is not _running_loop() + stale = self._connected_loop is not None and self._connected_loop is not _running_loop() try: await self._client.close() except Exception: @@ -487,7 +486,7 @@ async def aclose(self) -> None: self.name, ) self._initialized = False - self._loop = None + self._connected_loop = None async def __aenter__(self) -> Neo4jMemoryStore: await self.initialize() diff --git a/tests/integration/test_strands_memory_store_integration.py b/tests/integration/test_strands_memory_store_integration.py index 8c5416d7..d846239a 100644 --- a/tests/integration/test_strands_memory_store_integration.py +++ b/tests/integration/test_strands_memory_store_integration.py @@ -167,7 +167,7 @@ def test_an_owned_client_survives_the_loop_change_strands_forces(neo4j_connectio # Loop A — what Agent.__init__ does. run_async(store.initialize) - connecting_loop = store._loop + connecting_loop = store._connected_loop assert connecting_loop is not None # Loop B — what Agent.__call__ does. @@ -177,7 +177,7 @@ async def real_work() -> list[dict[str, object]]: try: assert run_async(real_work) == [{"n": 1}] - assert store._loop is not connecting_loop, "the store rebound to the new loop" + assert store._connected_loop is not connecting_loop, "the store rebound to the new loop" finally: run_async(store.aclose) From fda9d2b7710f4c8210797c3b4de311511efc512a Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 20 Aug 2026 15:15:08 +0200 Subject: [PATCH 32/39] test(strands): drive NAMS-mode unit tests through the real NAMS classes The hand-rolled NAMS fake was more permissive than the backend it stood in for and produced three review-caught bugs (invented Relationship attribute, bolt metadata that CREATE_CONVERSATION has no property for, (Entity, None) where NAMS returns a bare Entity). Remove the class of defect on the NAMS side: nams_mode=True now instantiates the real NamsShortTermMemory / NamsLongTermMemory over StubTransport, a stub of the single HttpTransport.request method (subclassed, so the constructor and signature are the real ones). Bolt keeps its duck-typed fakes -- the real bolt classes need a live driver -- and the docstring says why the asymmetry is deliberate. Assertions are re-pointed from vanished fake recorders to the recorded requests, which is what NAMS would actually receive: - add_entity: {"name": ..., "type": "organization"} -- the POLE+O type is mapped into NAMS' lowercase vocabulary, not passed through as the old fake's added_entities claimed. - list_conversations: query params {"userId": ..., "limit": 1000}. - add_message: {"content", "role"} only -- metadata, extract_entities and user_identifier are dropped at the boundary. - create_conversation: {"metadata": {...}} only; the Strands session id survives solely inside metadata. - The cross-instance sink reuse test now feeds the recorded create body back as the listing, pinning "the metadata written is the metadata matched on" instead of trusting a fake server to echo it. - The preference/fact skip tests assert no request and no swallowed failure, since NotSupportedError is caught by the gather. Bolt fakes lose their NAMS branches, the NAMS-only expand_graph, and the NotSupportedError impersonation they no longer need. --- tests/unit/integrations/strands_fakes.py | 341 +++++++++++++----- .../integrations/test_strands_memory_store.py | 78 ++-- .../test_strands_session_manager.py | 86 +++-- 3 files changed, 364 insertions(+), 141 deletions(-) diff --git a/tests/unit/integrations/strands_fakes.py b/tests/unit/integrations/strands_fakes.py index b075d9ee..06db666b 100644 --- a/tests/unit/integrations/strands_fakes.py +++ b/tests/unit/integrations/strands_fakes.py @@ -1,24 +1,210 @@ -"""Stateful fake MemoryClient for Strands session-manager unit tests. - -Implements only the methods Neo4jSessionManager touches, with NAMS-mode -semantics behind a flag (server-issued conversation UUIDs, kwargs -dropped on add_message). State-based tests beat call-sequence mocks for -round-trip behavior (append -> restore). +"""Test doubles for the Strands integration's unit tests. + +Two deliberately **asymmetric** backends behind one flag: + +* ``FakeMemoryClient(nams_mode=True)`` exposes the **real** + :class:`NamsShortTermMemory` / :class:`NamsLongTermMemory`, driven by + :class:`StubTransport` — a stub of the one narrow method the NAMS memory + classes use to reach the network + (``HttpTransport.request(spec, path_params=, json=, params=)``). Nothing + about the NAMS memory API is re-implemented here, so a test can no longer + assert against behaviour NAMS does not have. The observable surface is the + *requests the transport recorded*, which is what NAMS would really receive. + +* ``FakeMemoryClient()`` (bolt) keeps the hand-written duck-typed fakes below. + The real bolt classes drive a live Neo4j session through + ``Neo4jClient.execute_read/execute_write``, which a unit test cannot have; + bolt fidelity is pinned instead by + ``tests/integration/test_strands_memory_store_integration.py``. + +That asymmetry is intentional — please do not "finish the job" by making both +sides the same. Replacing the NAMS side with a duck-typed fake reintroduces +the class of defect this module exists to remove (a fake more permissive than +the backend it stands in for); replacing the bolt side with the real classes +requires a database. + +State-based tests beat call-sequence mocks for round-trip behavior +(append -> restore), which is why the bolt fakes are stateful. """ from __future__ import annotations import uuid +from dataclasses import dataclass, field from typing import Any -from neo4j_agent_memory.core.exceptions import MemoryError as NamMemoryError from neo4j_agent_memory.memory.short_term import Conversation, Message, MessageRole +from neo4j_agent_memory.nams.endpoints import EndpointSpec +from neo4j_agent_memory.nams.transport import HttpTransport + +# --------------------------------------------------------------------------- +# Stubbed NAMS wire +# --------------------------------------------------------------------------- + +#: Fixed timestamp for canned NAMS payloads (NAMS returns ISO 8601 strings). +_STUB_NOW = "2026-08-19T12:00:00+00:00" + +#: Sentinel: this NAMS operation has neither an override nor a canned default. +_MISSING = object() + + +@dataclass(frozen=True) +class StubCall: + """One request the NAMS memory classes made on the transport.""" + + method: str #: ``spec.bridge_method`` — the NAMS operation name. + spec: EndpointSpec + path_params: dict[str, object] = field(default_factory=dict) + json: Any = None + params: dict[str, Any] | None = None + + +def _canned_conversation(call: StubCall) -> dict[str, Any]: + """``POST /conversations`` — NAMS mints the id and echoes nothing else back. + + Deliberately does *not* echo ``metadata``: whether the create response + carries it is a server detail nothing in this package relies on (the + callers use ``created.id`` only). Assert on the recorded request body. + """ + return {"id": str(uuid.uuid4()), "createdAt": _STUB_NOW} + + +def _canned_message(call: StubCall) -> dict[str, Any]: + body = call.json if isinstance(call.json, dict) else {} + return { + "id": str(uuid.uuid4()), + "role": body.get("role", "user"), + "content": body.get("content", ""), + "createdAt": _STUB_NOW, + } + + +def _canned_bulk(call: StubCall) -> dict[str, Any]: + body = call.json if isinstance(call.json, dict) else {} + batch = body.get("messages") or [] + return { + "messages": [ + { + "id": str(uuid.uuid4()), + "role": m.get("role", "user"), + "content": m.get("content", ""), + "createdAt": _STUB_NOW, + } + for m in batch + ] + } + + +def _canned_entity(call: StubCall) -> dict[str, Any]: + body = call.json if isinstance(call.json, dict) else {} + return { + "id": str(uuid.uuid4()), + "name": body.get("name", ""), + # NAMS stores (and returns) its own lowercase type vocabulary; the + # memory class uppercases it again on the way back. + "type": body.get("type", "custom"), + "createdAt": _STUB_NOW, + } + + +#: Realistic empty/echo responses per NAMS operation, matching the response +#: envelopes the memory classes parse. A test overrides any of these via +#: ``transport.responses[method] = payload`` (or a ``StubCall -> payload`` +#: callable). An operation with neither an override nor a default raises — +#: an unexpected NAMS call must never quietly succeed. +_CANNED: dict[str, Any] = { + "create_conversation": _canned_conversation, + "list_conversations": {"conversations": []}, + "get_conversation": {"createdAt": _STUB_NOW}, + "list_messages": {"messages": []}, + "add_message": _canned_message, + "bulk_add_messages": _canned_bulk, + "search_messages": {"messages": [], "searchType": "vector"}, + "delete_conversation": None, + "add_entity": _canned_entity, + "search_entities": {"entities": [], "searchType": "vector"}, + "expand_graph": {"nodes": [], "edges": []}, + "get_extraction_status": {"messages": [], "summary": {}}, +} + + +class _NoAuth: + """``AuthProvider`` that adds no headers (nothing reaches the network).""" + + async def apply(self, headers: dict[str, str]) -> dict[str, str]: + return headers + + +class StubTransport(HttpTransport): + """Records NAMS requests and answers them from canned payloads. + + Subclasses the real :class:`HttpTransport` rather than duck-typing it, so + the stub is bound to the actual constructor and the actual ``request`` + signature: if ``request`` grows or renames a parameter, the NAMS memory + classes call this override with the new keyword and the tests fail with a + ``TypeError`` instead of silently drifting. No socket is ever opened — + ``request`` is overridden above the point where the httpx client is built. + """ + + def __init__(self) -> None: + # A ``/v1`` endpoint so ``detect_protocol`` selects the REST wire, the + # one the hosted service speaks. + super().__init__(endpoint="https://nams.invalid/v1", auth=_NoAuth()) + self.calls: list[StubCall] = [] + #: NAMS operation name -> payload, or a ``StubCall -> payload`` callable. + self.responses: dict[str, Any] = {} + + async def request( + self, + spec: EndpointSpec, + *, + path_params: dict[str, object] | None = None, + json: Any = None, + params: dict[str, Any] | None = None, + ) -> Any: + call = StubCall( + method=spec.bridge_method, + spec=spec, + path_params=dict(path_params or {}), + json=json, + params=params, + ) + self.calls.append(call) + canned = self.responses.get(spec.bridge_method, _CANNED.get(spec.bridge_method, _MISSING)) + if canned is _MISSING: + raise AssertionError( + f"StubTransport: unexpected NAMS call {spec.bridge_method!r} " + f"({spec.rest_method} {spec.rest_path}). Set " + f"transport.responses[{spec.bridge_method!r}] if the test intends it." + ) + return canned(call) if callable(canned) else canned + + # ------------------------------------------------------------ assertions + + @property + def methods(self) -> list[str]: + """NAMS operation names, in call order.""" + return [call.method for call in self.calls] + + def calls_for(self, method: str) -> list[StubCall]: + return [call for call in self.calls if call.method == method] + + def last(self, method: str) -> StubCall: + calls = self.calls_for(method) + assert calls, f"no {method!r} call was made (calls: {self.methods})" + return calls[-1] + + +# --------------------------------------------------------------------------- +# Bolt fakes (see the module docstring for why these are hand-written) +# --------------------------------------------------------------------------- class FakeShortTerm: - def __init__(self, nams_mode: bool) -> None: - self._nams_mode = nams_mode - # key -> Conversation. Bolt: key == session_id. NAMS: key == str(uuid). + """Duck-typed stand-in for the bolt ``ShortTermMemory``.""" + + def __init__(self) -> None: + #: session_id -> Conversation (bolt keys conversations by session_id). self.conversations: dict[str, Conversation] = {} self.add_message_calls: list[dict[str, Any]] = [] self.bulk_calls: list[dict[str, Any]] = [] @@ -31,18 +217,10 @@ def __init__(self, nams_mode: bool) -> None: async def create_conversation( self, session_id: str | None = None, **kwargs: Any ) -> Conversation: - conv_id = uuid.uuid4() - key = str(conv_id) if self._nams_mode else str(session_id) - # Real bolt's CREATE_CONVERSATION has no metadata property; only NAMS - # accepts and stores it. Mirror that so bolt-mode tests can't lean on - # metadata a real bolt conversation would never carry. - metadata = kwargs.get("metadata") or {} if self._nams_mode else {} - conv = Conversation( - id=conv_id, - session_id=str(session_id), - metadata=metadata, - ) - self.conversations[key] = conv + # Bolt's CREATE_CONVERSATION has no metadata property, so a bolt + # conversation never carries metadata no matter what the caller passes. + conv = Conversation(id=uuid.uuid4(), session_id=str(session_id), metadata={}) + self.conversations[str(session_id)] = conv return conv async def list_conversations(self, **kwargs: Any) -> list[Conversation]: @@ -54,8 +232,6 @@ async def get_conversation(self, session_id: str, **kwargs: Any) -> Conversation self.get_conversation_kwargs.append({"session_id": session_id, **kwargs}) conv = self.conversations.get(session_id) if conv is None: - if self._nams_mode: - raise NamMemoryError(f"NAMS: conversation {session_id} not found") # Bolt contract: empty conversation, no exception. return Conversation(session_id=session_id) return conv @@ -64,16 +240,10 @@ async def add_message(self, session_id: str, role: str, content: str, **kwargs: if self.fail_next_add: self.fail_next_add = False raise RuntimeError("backend down") - # Real NAMS accepts only {content, role} on add_message and silently - # drops everything else (metadata, user_identifier, bolt-only knobs). - # Mirror that here so NAMS-mode tests can't lean on dropped kwargs. - recorded = {"session_id": session_id, "role": role, "content": content} - if not self._nams_mode: - recorded.update(kwargs) - self.add_message_calls.append(recorded) + self.add_message_calls.append( + {"session_id": session_id, "role": role, "content": content, **kwargs} + ) if session_id not in self.conversations: - if self._nams_mode: - raise NamMemoryError(f"NAMS: unknown conversation {session_id}") await self.create_conversation(session_id=session_id) msg = Message(role=MessageRole(role), content=content) self.conversations[session_id].messages.append(msg) @@ -92,34 +262,25 @@ async def bulk_add_messages( # Explicit parameters mirroring ShortTermProtocol.bulk_add_messages # (no **kwargs catch-all) so this fake can't absorb a keyword the # real bolt backend would reject. - kwargs = { - "generate_embeddings": generate_embeddings, - "extract_entities": extract_entities, - "extract_relations": extract_relations, - "user_identifier": user_identifier, - } - recorded_kwargs = {} if self._nams_mode else kwargs self.bulk_calls.append( - {"session_id": session_id, "messages": messages, "kwargs": recorded_kwargs} + { + "session_id": session_id, + "messages": messages, + "kwargs": { + "generate_embeddings": generate_embeddings, + "extract_entities": extract_entities, + "extract_relations": extract_relations, + "user_identifier": user_identifier, + }, + } ) if session_id not in self.conversations: - if self._nams_mode: - raise NamMemoryError(f"NAMS: unknown conversation {session_id}") await self.create_conversation(session_id=session_id, user_identifier=user_identifier) stored = [Message(role=MessageRole(m["role"]), content=m["content"]) for m in messages] self.conversations[session_id].messages.extend(stored) return stored async def delete_message(self, message_id: Any, **kwargs: Any) -> bool: - if self._nams_mode: - from neo4j_agent_memory.core.exceptions import NotSupportedError - - raise NotSupportedError( - backend="nams", - method="ShortTermMemory.delete_message", - message="NAMS does not expose a message-delete endpoint.", - workaround="Use clear_session(session_id) to clear an entire conversation.", - ) self.deleted_message_ids.append(str(message_id)) for conv in self.conversations.values(): conv.messages = [m for m in conv.messages if str(m.id) != str(message_id)] @@ -127,6 +288,14 @@ async def delete_message(self, message_id: Any, **kwargs: Any) -> bool: class FakeLongTerm: + """Duck-typed stand-in for the bolt ``LongTermMemory``. + + Bolt-only by construction: no ``expand_graph`` (that method exists on + NAMS alone), and every method behaves the way the bolt implementation + behaves — including ``add_entity`` returning the + ``(Entity, DeduplicationResult)`` tuple. + """ + def __init__(self) -> None: self.entities: list[Any] = [] self.preferences: list[Any] = [] @@ -139,11 +308,8 @@ def __init__(self) -> None: self.added_preferences: list[tuple[str, str]] = [] self.added_facts: list[tuple[str, str, str]] = [] self.added_entities: list[tuple[str, str]] = [] - self.nams_mode = False self.related: list[Any] = [] self.related_kwargs: list[dict[str, Any]] = [] - self.expansion: dict[str, list[dict[str, Any]]] = {"nodes": [], "edges": []} - self.expand_calls: list[str] = [] self.preferences_for: list[Any] = [] self.preferences_for_calls: list[dict[str, Any]] = [] #: Preferences that got a ``(:User)-[:HAS_PREFERENCE]`` edge, keyed by @@ -158,16 +324,6 @@ async def _maybe_fail(self) -> None: if self.fail_searches: raise RuntimeError("search backend down") - def _reject_on_nams(self, method: str) -> None: - if self.nams_mode: - from neo4j_agent_memory.core.exceptions import NotSupportedError - - raise NotSupportedError( - backend="nams", - method=f"LongTermMemory.{method}", - message="NAMS provides entity endpoints only.", - ) - async def search_entities(self, query: str, **kwargs: Any) -> list[Any]: self.search_calls += 1 self.search_kwargs.append({"query": query, **kwargs}) @@ -177,7 +333,6 @@ async def search_entities(self, query: str, **kwargs: Any) -> list[Any]: async def search_preferences(self, query: str, **kwargs: Any) -> list[Any]: self.search_calls += 1 self.search_kwargs.append({"query": query, **kwargs}) - self._reject_on_nams("search_preferences") if self.fail_preferences: raise RuntimeError("preference backend down") await self._maybe_fail() @@ -186,7 +341,6 @@ async def search_preferences(self, query: str, **kwargs: Any) -> list[Any]: async def search_facts(self, query: str, **kwargs: Any) -> list[Any]: self.search_calls += 1 self.search_kwargs.append({"query": query, **kwargs}) - self._reject_on_nams("search_facts") if self.fail_facts: raise RuntimeError("fact backend down") await self._maybe_fail() @@ -195,7 +349,6 @@ async def search_facts(self, query: str, **kwargs: Any) -> list[Any]: async def add_preference( self, category: str, preference: str, *, user_identifier: str | None = None, **kwargs: Any ) -> Any: - self._reject_on_nams("add_preference") if self.multi_tenant and user_identifier is None: raise ValueError( "MemorySettings.memory.multi_tenant=True but no user_identifier was supplied." @@ -212,7 +365,6 @@ async def add_preference( return stored async def add_fact(self, subject: str, predicate: str, obj: str, **kwargs: Any) -> Any: - self._reject_on_nams("add_fact") self.added_facts.append((subject, predicate, obj)) from neo4j_agent_memory.memory.long_term import Fact @@ -222,12 +374,9 @@ async def add_entity(self, name: str, entity_type: str, **kwargs: Any) -> Any: from neo4j_agent_memory.memory.long_term import Entity self.added_entities.append((name, entity_type)) - entity = Entity(name=name, type=entity_type) - # Real NAMS add_entity returns a bare Entity (nams/long_term.py:205-210); - # bolt returns (Entity, DeduplicationResult). - if self.nams_mode: - return entity - return entity, None + # Bolt returns (Entity, DeduplicationResult); NAMS returns a bare + # Entity -- and that path now runs the real NamsLongTermMemory. + return Entity(name=name, type=entity_type), None async def get_related_entities(self, entity: Any, **kwargs: Any) -> list[tuple[Any, Any]]: """Real ``Relationship`` objects, shaped as the bolt path really shapes them. @@ -242,7 +391,6 @@ async def get_related_entities(self, entity: Any, **kwargs: Any) -> list[tuple[A fake reproduces that rather than inventing a richer relationship the production stack never returns. """ - self._reject_on_nams("get_related_entities") self.related_kwargs.append(kwargs) from neo4j_agent_memory.memory.long_term import Relationship @@ -255,17 +403,20 @@ async def get_related_entities(self, entity: Any, **kwargs: Any) -> list[tuple[A for other in self.related ] - async def expand_graph(self, node_id: str, **kwargs: Any) -> dict[str, list[dict[str, Any]]]: - self.expand_calls.append(str(node_id)) - return self.expansion - async def get_preferences_for(self, user_identifier: str, **kwargs: Any) -> list[Any]: - self._reject_on_nams("get_preferences_for") self.preferences_for_calls.append({"user_identifier": user_identifier, **kwargs}) return [*self.preferences_for, *self.preferences_by_user.get(user_identifier, [])] class FakeReasoning: + """Duck-typed reasoning layer. + + Bolt-shaped, and used in bolt mode only: the session manager's tool-call + mirroring is exercised there. If a NAMS-mode test ever needs reasoning, + wire ``NamsReasoningMemory`` to the ``StubTransport`` instead of extending + this class. + """ + def __init__(self) -> None: self.traces: list[dict[str, Any]] = [] self.steps: list[dict[str, Any]] = [] @@ -297,18 +448,38 @@ async def record_tool_call( class FakeMemoryClient: - """Duck-typed MemoryClient covering the session manager's surface.""" + """Duck-typed MemoryClient covering the Strands integration's surface. + + In NAMS mode ``short_term`` / ``long_term`` are the real NAMS classes over + :attr:`transport`; in bolt mode they are the fakes above. + """ def __init__(self, nams_mode: bool = False) -> None: self._nams_mode = nams_mode - self.short_term = FakeShortTerm(nams_mode) - self.long_term = FakeLongTerm() - self.long_term.nams_mode = nams_mode + self.transport: StubTransport | None = None + self.short_term: Any + self.long_term: Any + if nams_mode: + from neo4j_agent_memory.nams.long_term import NamsLongTermMemory + from neo4j_agent_memory.nams.short_term import NamsShortTermMemory + + self.transport = StubTransport() + self.short_term = NamsShortTermMemory(self.transport) + self.long_term = NamsLongTermMemory(self.transport) + else: + self.short_term = FakeShortTerm() + self.long_term = FakeLongTerm() self.reasoning = FakeReasoning() self.connect_calls = 0 self.close_calls = 0 self._connected = False + @property + def wire(self) -> StubTransport: + """The stubbed NAMS wire (NAMS mode only) — where the assertions live.""" + assert self.transport is not None, "wire is NAMS-mode only" + return self.transport + @property def is_nams(self) -> bool: return self._nams_mode diff --git a/tests/unit/integrations/test_strands_memory_store.py b/tests/unit/integrations/test_strands_memory_store.py index 868a936e..4671b1de 100644 --- a/tests/unit/integrations/test_strands_memory_store.py +++ b/tests/unit/integrations/test_strands_memory_store.py @@ -10,6 +10,9 @@ from tests.unit.integrations.strands_fakes import FakeMemoryClient +#: Entity ids in canned NAMS payloads must parse as UUIDs (``Entity.id: UUID``). +_ENTITY_ID = "11111111-1111-1111-1111-111111111111" + def _store(**kw: Any) -> Any: """Build a Neo4jMemoryStore from loose kwargs via its real config dataclass. @@ -293,22 +296,35 @@ async def test_reuses_an_existing_sink_across_instances(self) -> None: @pytest.mark.asyncio async def test_reuses_the_nams_server_minted_id_by_metadata(self) -> None: - """NAMS mints conversation ids, so reuse matches on metadata, not id.""" + """NAMS mints conversation ids, so reuse matches on metadata, not id. + + The reuse contract spans two round-trips, so it is asserted across + them: the first store's ``POST /conversations`` body is fed back as + the second store's ``GET /conversations`` listing. That pins the + invariant that actually matters -- the metadata the store *writes* + is the metadata it later *matches on* -- rather than trusting a fake + server to echo it. + """ client = FakeMemoryClient(nams_mode=True) first = _store(name="graph", client=client) await first.initialize() key_one = await first._resolve_sink() + created = client.wire.last("create_conversation") + assert created.json["metadata"]["strands_memory_store"] == "strands-memory-store/_/graph" + client.wire.responses["list_conversations"] = { + "conversations": [{"id": key_one, "metadata": created.json["metadata"]}] + } + second = _store(name="graph", client=client) await second.initialize() key_two = await second._resolve_sink() assert key_one == key_two assert key_one != "strands-memory-store/_/graph" # the cached key is the minted uuid - only_conv = next(iter(client.short_term.conversations.values())) - assert key_one == str(only_conv.id) - assert len(client.short_term.conversations) == 1 + # Exactly one conversation was ever created: the second store matched. + assert len(client.wire.calls_for("create_conversation")) == 1 @pytest.mark.asyncio async def test_nams_scans_conversations_once(self) -> None: @@ -319,7 +335,7 @@ async def test_nams_scans_conversations_once(self) -> None: await store.initialize() await store._resolve_sink() - assert len(client.short_term.list_conversations_calls) == 1 + assert len(client.wire.calls_for("list_conversations")) == 1 @pytest.mark.asyncio async def test_explicit_conversation_id_is_used_verbatim(self) -> None: @@ -466,11 +482,20 @@ async def test_kind_flags_are_honoured(self) -> None: @pytest.mark.asyncio async def test_nams_returns_entities_only(self) -> None: - from neo4j_agent_memory.memory.long_term import Entity, Preference + """NAMS has entity search only, so that is the only request made. + + The real ``NamsLongTermMemory.search_preferences`` / + ``search_facts`` raise ``NotSupportedError``, and ``_retrieve_entries`` + swallows per-kind failures -- so "only entities came back" alone would + pass even if they had been called. The recorded call list is what + proves they were skipped. + """ client = FakeMemoryClient(nams_mode=True) - client.long_term.entities = [Entity(name="Acme Corp", type="ORGANIZATION")] - client.long_term.preferences = [Preference(category="ui", preference="dark mode")] + client.wire.responses["search_entities"] = { + "entities": [{"id": _ENTITY_ID, "name": "Acme Corp", "type": "organization"}], + "searchType": "vector", + } store = _store(name="graph", client=client) await store.initialize() @@ -479,6 +504,7 @@ async def test_nams_returns_entities_only(self) -> None: assert len(entries) == 1 assert entries[0].metadata is not None assert entries[0].metadata["kind"] == "entity" + assert client.wire.methods == ["search_entities"] @pytest.mark.asyncio async def test_search_does_not_mint_a_sink(self) -> None: @@ -496,8 +522,7 @@ async def test_search_does_not_mint_a_sink(self) -> None: await store.initialize() await store.search("q") - assert client.short_term.list_conversations_calls == [] - assert client.short_term.conversations == {} + assert client.wire.methods == ["search_entities"] # no list, no create @pytest.mark.asyncio async def test_search_initializes_without_a_prior_initialize_call(self) -> None: @@ -584,6 +609,11 @@ async def test_kind_entity_on_nams_returns_bare_entity_id(self) -> None: Without the isinstance narrowing in `_add_typed`, this raises trying to subscript a bare Entity as a tuple. + + Note the POLE+O type is *not* what reaches the server: + ``NamsLongTermMemory`` maps it into NAMS' own lowercase vocabulary + (``ORGANIZATION`` -> ``organization``) and drops everything NAMS' + create body has no field for. """ client = FakeMemoryClient(nams_mode=True) @@ -591,7 +621,7 @@ async def test_kind_entity_on_nams_returns_bare_entity_id(self) -> None: await store.initialize() result = await store.add("Acme Corp", {"kind": "entity", "type": "ORGANIZATION"}) - assert client.long_term.added_entities == [("Acme Corp", "ORGANIZATION")] + assert client.wire.last("add_entity").json == {"name": "Acme Corp", "type": "organization"} assert result["kind"] == "entity" assert result["id"] @@ -603,7 +633,9 @@ async def test_unsupported_kind_on_nams_falls_back_to_the_sink(self, caplog) -> result = await store.add("dark mode", {"kind": "preference", "category": "ui"}) assert result["kind"] == "message" - assert client.short_term.add_message_calls[-1]["content"] == "dark mode" + # NAMS accepts only {content, role} on a message: the sink write carries + # no category, no user identifier, no extraction flag. + assert client.wire.last("add_message").json == {"content": "dark mode", "role": "user"} assert "falling back" in caplog.text.lower() @pytest.mark.asyncio @@ -618,7 +650,7 @@ async def test_unsupported_kind_warning_is_logged_once_per_store(self, caplog) - warnings = [r for r in caplog.records if "unsupported on this backend" in r.message] assert len(warnings) == 1 - assert len(client.short_term.add_message_calls) == 2 + assert len(client.wire.calls_for("add_message")) == 2 @pytest.mark.asyncio async def test_rejects_writes_when_not_writable(self) -> None: @@ -893,22 +925,26 @@ async def test_entity_graph_caps_edges_at_max_edges_on_bolt(self) -> None: async def test_entity_graph_uses_expand_graph_on_nams(self) -> None: """NAMS: name resolved via search, then a 1-hop expansion by node id.""" from neo4j_agent_memory.integrations.strands._store_tools import _entity_graph - from neo4j_agent_memory.memory.long_term import Entity client = FakeMemoryClient(nams_mode=True) - centre = Entity(name="Acme Corp", type="ORGANIZATION") - client.long_term.entities = [centre] - client.long_term.expansion = { + expansion = { "nodes": [{"id": "n2", "name": "Ada", "type": "PERSON"}], - "edges": [{"from": "n2", "to": str(centre.id), "type": "WORKS_AT"}], + "edges": [{"from": "n2", "to": _ENTITY_ID, "type": "WORKS_AT"}], + } + client.wire.responses["search_entities"] = { + "entities": [{"id": _ENTITY_ID, "name": "Acme Corp", "type": "organization"}] } + client.wire.responses["expand_graph"] = expansion result = await _entity_graph(client, "Acme Corp", depth=3, nams=True) - assert client.long_term.expand_calls == [str(centre.id)] + # The searched name goes out as the query; the *id* it resolved to is + # what the expansion is keyed by (NAMS has no expand-by-name). + assert client.wire.last("search_entities").json == {"query": "Acme Corp", "limit": 1} + assert client.wire.last("expand_graph").json == {"nodeId": _ENTITY_ID, "loadedIds": []} assert result["depth"] == 1 # 1 hop is all NAMS offers - assert result["nodes"] == client.long_term.expansion["nodes"] - assert result["edges"] == client.long_term.expansion["edges"] + assert result["nodes"] == expansion["nodes"] + assert result["edges"] == expansion["edges"] @pytest.mark.asyncio async def test_entity_graph_reports_an_unknown_entity(self) -> None: diff --git a/tests/unit/integrations/test_strands_session_manager.py b/tests/unit/integrations/test_strands_session_manager.py index 11f8d7e5..6ff5c495 100644 --- a/tests/unit/integrations/test_strands_session_manager.py +++ b/tests/unit/integrations/test_strands_session_manager.py @@ -160,6 +160,9 @@ def test_formatters(self) -> None: from types import SimpleNamespace +#: Entity ids in canned NAMS payloads must parse as UUIDs (``Entity.id: UUID``). +_ENTITY_ID = "11111111-1111-1111-1111-111111111111" + def _make_manager(nams_mode: bool = False, **kwargs): """Build a manager wired to a FakeMemoryClient. Caller must close().""" @@ -213,21 +216,20 @@ def test_bolt_uses_session_id_directly_and_restores_history(self) -> None: manager.close() def test_nams_resolves_existing_conversation_by_metadata(self) -> None: - import asyncio - manager, client = _make_manager(nams_mode=True) + existing_id = "22222222-2222-2222-2222-222222222222" + client.wire.responses["list_conversations"] = { + "conversations": [ + {"id": existing_id, "metadata": {"strands_session_id": "sess-1"}}, + {"id": "33333333-3333-3333-3333-333333333333", "metadata": {}}, + ] + } try: - existing = asyncio.run( - client.short_term.create_conversation( - session_id="sess-1", - metadata={"strands_session_id": "sess-1"}, - ) - ) agent = _fake_agent() manager.initialize(agent) - assert manager._conversation_key == str(existing.id) + assert manager._conversation_key == existing_id # No second conversation was created. - assert len(client.short_term.conversations) == 1 + assert client.wire.calls_for("create_conversation") == [] finally: manager.close() @@ -236,10 +238,19 @@ def test_nams_creates_conversation_when_absent(self) -> None: try: agent = _fake_agent() manager.initialize(agent) - assert len(client.short_term.conversations) == 1 - conv = next(iter(client.short_term.conversations.values())) - assert conv.metadata["strands_session_id"] == "sess-1" - assert manager._conversation_key == str(conv.id) + created = client.wire.last("create_conversation") + # NAMS' create body is {userId?, metadata?} -- session_id and title + # are client-side concepts and never reach the server, so the + # Strands session id survives the round trip only inside metadata. + assert created.json == { + "metadata": {"strands_session_id": "sess-1", "session_type": "AGENT"} + } + assert manager._conversation_key + # The key is the server-minted id from the create response, and the + # subsequent history load is scoped to it. + assert client.wire.last("get_conversation").path_params == { + "conversation_id": manager._conversation_key + } finally: manager.close() @@ -499,6 +510,11 @@ def test_late_redaction_on_nams_warns_and_does_not_raise(self, caplog) -> None: {"role": "user", "content": [{"text": "[REDACTED]"}]}, agent ) assert any("redact" in r.message.lower() for r in caplog.records) + # Nothing was attempted on the wire: the original message stands and + # no second write followed it. (NAMS' real delete_message raises + # NotSupportedError, so a call would surface as an exception here.) + assert [c.method for c in client.wire.calls_for("add_message")] == ["add_message"] + assert client.wire.last("add_message").json["content"] == "secret" finally: manager.close() @@ -657,7 +673,16 @@ def test_injection_is_idempotent_for_same_message(self) -> None: finally: manager.close() - def test_nams_skips_unsupported_preference_and_fact_searches(self) -> None: + def test_nams_skips_unsupported_preference_and_fact_searches(self, caplog) -> None: + """The real NAMS methods raise ``NotSupportedError``; they must not be called. + + ``_retrieve_context`` gathers with ``return_exceptions=True`` and logs + per-kind failures, so a call would not fail the turn -- it would just + log. Hence both checks: the entity line is present, and neither a + preference/fact request nor a "search failed" warning appeared. + """ + import logging + from neo4j_agent_memory.integrations.strands.session_manager import ( Neo4jRetrievalConfig, ) @@ -665,27 +690,18 @@ def test_nams_skips_unsupported_preference_and_fact_searches(self) -> None: manager, client = _make_manager( nams_mode=True, retrieval_config=Neo4jRetrievalConfig(include_facts=True) ) - client.long_term.entities = [ - SimpleNamespace( - display_name="Acme", - type="ORGANIZATION", - full_type="ORGANIZATION", - description=None, - ) - ] - - # Make preference/fact searches behave like real NAMS: raise if called. - async def boom(query, **kwargs): - raise AssertionError("must not be called on NAMS") - - client.long_term.search_preferences = boom - client.long_term.search_facts = boom + client.wire.responses["search_entities"] = { + "entities": [{"id": _ENTITY_ID, "name": "Acme", "type": "organization"}] + } try: manager.initialize(_fake_agent()) message = {"role": "user", "content": [{"text": "Acme?"}]} - manager._inject_context(message) + with caplog.at_level(logging.WARNING): + manager._inject_context(message) text = message["content"][0]["text"] assert "[entity] Acme (ORGANIZATION)" in text # entity search still works + assert client.wire.methods.count("search_entities") == 1 + assert "search failed" not in caplog.text.lower() finally: manager.close() @@ -798,11 +814,11 @@ def test_nams_list_conversations_passes_user_identifier_and_limit(self) -> None: try: agent = _fake_agent() manager.initialize(agent) - # _aresolve_conversation calls list_conversations with scoping kwargs. - calls = client.short_term.list_conversations_calls + # _aresolve_conversation calls list_conversations with scoping kwargs, + # which NAMS receives as the camelCase query string ?userId=&limit=. + calls = client.wire.calls_for("list_conversations") assert len(calls) == 1 - assert calls[0].get("user_identifier") == "alice" - assert calls[0].get("limit") == 1000 + assert calls[0].params == {"userId": "alice", "limit": 1000} finally: manager.close() From 537903b9c5cfb656b13b225208777e0531f1ade7 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 20 Aug 2026 15:17:00 +0200 Subject: [PATCH 33/39] fix(strands): narrow the NAMS long-term layer with isinstance, not a cast _entity_graph's NAMS branch cast client.long_term to NamsLongTermMemory and called expand_graph on faith. Inside that branch httpx is installed by definition, so the concrete class can simply be imported and checked -- a mismatched backend now raises a TypeError naming what it got instead of an AttributeError from the call. Nested import, matching how for_nams imports build_nams_settings; the now-redundant TYPE_CHECKING entry is gone. Only testable now that nams_mode=True instantiates the real NamsLongTermMemory: against the old duck-typed fake, isinstance would have failed. The bolt branch keeps its cast -- LongTermProtocol declares get_related_entities without the depth parameter, so narrowing to the concrete class is genuinely required there. --- .../integrations/strands/_store_tools.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/neo4j_agent_memory/integrations/strands/_store_tools.py b/src/neo4j_agent_memory/integrations/strands/_store_tools.py index 3d8a7e58..ef7197c6 100644 --- a/src/neo4j_agent_memory/integrations/strands/_store_tools.py +++ b/src/neo4j_agent_memory/integrations/strands/_store_tools.py @@ -21,7 +21,6 @@ from neo4j_agent_memory import MemoryClient from neo4j_agent_memory.integrations.strands.memory_store import Neo4jMemoryStore from neo4j_agent_memory.memory.long_term import LongTermMemory - from neo4j_agent_memory.nams.long_term import NamsLongTermMemory _MAX_EDGES = 50 @@ -41,10 +40,20 @@ async def _entity_graph( centre = matches[0] if nams: - # expand_graph is NAMS-only, excluded from LongTermProtocol -- cast to - # the concrete class so the call stays checked instead of untyped. - nams_long_term = cast("NamsLongTermMemory", client.long_term) - expansion = await nams_long_term.expand_graph(str(centre.id)) + # expand_graph is NAMS-only, excluded from LongTermProtocol. Narrow with + # a real check rather than a cast: inside this branch httpx is installed + # by definition, so importing the NAMS module here is safe -- the same + # nested-import shape `for_nams` uses for `build_nams_settings`. + from neo4j_agent_memory.nams.long_term import NamsLongTermMemory + + long_term = client.long_term + if not isinstance(long_term, NamsLongTermMemory): + raise TypeError( + f"Neo4jMemoryStore: the client reports the NAMS backend, but its " + f"long_term layer is {type(long_term).__name__}, not " + f"NamsLongTermMemory -- expand_graph exists only on NAMS." + ) + expansion = await long_term.expand_graph(str(centre.id)) return { "center": centre.display_name, "depth": 1, From 808f60915df5d5da18172a13f871c6e9145a3f33 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 20 Aug 2026 15:56:47 +0200 Subject: [PATCH 34/39] fix(nams): correct stale metadata docstring; add key-gated sink-reuse test _normalize_conversation's docstring said NAMS omits `metadata` from both GET and create responses. A live probe showed metadata round-trips through create and list_conversations, which is what Neo4jMemoryStore._resolve_nams_sink relies on. Docstring corrected, no behavior change. Adds the key-gated NAMS integration test promised in the design spec's Testing table but never shipped: two Neo4jMemoryStore instances with the same name/user_id resolve to the same sink, backed by exactly one metadata-tagged conversation. Reuses conftest.py's credential resolution and skip gate. --- src/neo4j_agent_memory/nams/short_term.py | 14 ++- .../nams/test_strands_memory_store.py | 92 +++++++++++++++++++ 2 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 tests/integration/nams/test_strands_memory_store.py diff --git a/src/neo4j_agent_memory/nams/short_term.py b/src/neo4j_agent_memory/nams/short_term.py index 9e65c5cc..c6f1af27 100644 --- a/src/neo4j_agent_memory/nams/short_term.py +++ b/src/neo4j_agent_memory/nams/short_term.py @@ -185,11 +185,15 @@ def _normalize_conversation( ) -> dict[str, Any]: """Map NAMS Conversation response → bolt Pydantic shape. - NAMS returns ``{id, userId, workspaceId, createdAt, updatedAt}`` from - GET, and only ``{id, userId, workspaceId}`` from create. The bolt - Pydantic ``Conversation`` model requires ``id``, ``session_id``, and - ``created_at``. We synthesize ``session_id`` from the caller-supplied - value (which is typically the NAMS conversation UUID). + NAMS returns ``{id, userId, workspaceId, metadata, createdAt, + updatedAt}`` from GET, and ``{id, userId, workspaceId, metadata}`` + from create — verified live: ``metadata`` round-trips through both + create and ``list_conversations``. The bolt Pydantic ``Conversation`` + model requires ``id``, ``session_id``, and ``created_at``. We + synthesize ``session_id`` from the caller-supplied value (which is + typically the NAMS conversation UUID). ``metadata`` still defaults to + ``{}`` below when absent, since older or differently-configured NAMS + deployments may omit it. """ data = snakeize_keys(payload) if isinstance(payload, dict) else {} if "session_id" not in data: diff --git a/tests/integration/nams/test_strands_memory_store.py b/tests/integration/nams/test_strands_memory_store.py new file mode 100644 index 00000000..73be447b --- /dev/null +++ b/tests/integration/nams/test_strands_memory_store.py @@ -0,0 +1,92 @@ +"""Live-NAMS integration test — ``Neo4jMemoryStore`` sink resolution. + +Verifies the metadata round-trip a manual live probe confirmed (see +``.superpowers/sdd/2026-08-19-strands-memory-store/nams-live-verification-report.md``): +NAMS returns conversation ``metadata`` on both ``create`` and +``list_conversations``, which is exactly what +``Neo4jMemoryStore._resolve_nams_sink`` depends on to find its existing +sink across restarts instead of minting a fresh conversation every time. + +The spec's Testing table (``docs/superpowers/specs/2026-08-19-strands-memory-store-design.md``) +promised this suite as "key-gated NAMS ... not yet implemented" — this +closes that gap. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + +import pytest +import pytest_asyncio + +pytest.importorskip("strands", reason="strands-agents not installed") + +from neo4j_agent_memory import MemoryClient +from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore, Neo4jMemoryStoreConfig +from neo4j_agent_memory.integrations.strands.memory_store import _STORE_KEY + +pytestmark = pytest.mark.integration + + +@pytest_asyncio.fixture +async def _sink_cleanup(nams_client: MemoryClient) -> AsyncIterator[list[str]]: + """Tracks conversation ids this test resolves/creates; best-effort teardown. + + Runs even when an assertion above fails, so a broken test never leaves + a conversation behind in the shared sandbox workspace. + """ + created: list[str] = [] + try: + yield created + finally: + for conversation_id in created: + try: + await nams_client.short_term.clear_session(conversation_id) + except Exception: # noqa: BLE001 - best-effort teardown + pass + + +@pytest.mark.asyncio +async def test_resolve_sink_reuses_metadata_tagged_conversation( + nams_client: MemoryClient, + test_run_id: str, + _sink_cleanup: list[str], +) -> None: + """A second store with the same ``name``/``user_id`` resolves the same sink. + + ``test_run_id`` is a fresh UUID-suffixed prefix per test invocation (see + ``conftest.py``), so the store name here can't collide with, or be + mistaken for, another concurrent or prior run's sink. + """ + store_name = f"{test_run_id}-store" + user_id = f"{test_run_id}-user" + + store_a = Neo4jMemoryStore( + Neo4jMemoryStoreConfig(name=store_name, client=nams_client, user_id=user_id) + ) + store_b = Neo4jMemoryStore( + Neo4jMemoryStoreConfig(name=store_name, client=nams_client, user_id=user_id) + ) + + # First store: no existing sink yet -- creates one, tagged with metadata. + sink_a = await store_a._resolve_sink() + _sink_cleanup.append(sink_a) + + # Second, independent store instance with the same name/user_id: must + # find the same sink via the metadata tag rather than creating another. + sink_b = await store_b._resolve_sink() + + assert sink_a == sink_b + + conversations = await nams_client.short_term.list_conversations( + user_identifier=user_id, limit=1000 + ) + matching = [ + conversation + for conversation in conversations + if (conversation.metadata or {}).get(_STORE_KEY) == store_a._sink_name + ] + assert len(matching) == 1, ( + f"expected exactly one conversation tagged with this store's sink, found {len(matching)}" + ) + assert str(matching[0].id) == sink_a From 6dd782b422f352af59b953b982839c92445ff981 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 20 Aug 2026 16:06:46 +0200 Subject: [PATCH 35/39] test(strands): drive a real Agent against live NAMS on the synchronous path Every existing test of Neo4jMemoryStore drives the store directly, on one event loop -- the blind spot that let a Critical through review. Strands' synchronous entry point runs Agent.__init__ on one throwaway loop and each Agent.__call__ on another, so a settings=-constructed store connected its client on the construction loop and then raised "attached to a different loop" on the first call. initialize() now rebinds an owned client; nothing exercised that end-to-end. tests/e2e/test_strands_agent_nams_e2e.py constructs a real Agent over hosted NAMS and a local tool-calling Ollama model, calls it twice synchronously (construction-loop -> call-loop, then call-loop -> call-loop), and asserts the memory landed in the sink conversation, comes back out of store.search(), that the store's graph tool is registered under its namespaced name while get_user_preferences is absent on NAMS, and that MemoryManager's block reached the model (read off a RecordingModel subclass overriding the public Model.stream -- no privates). A sync test on purpose: asyncio_mode="auto" would otherwise run the body inside a loop, and the fresh-loop-per-call entry point is the thing under test. Extraction is awaited via wait_for_extraction/get_extraction_status, never a sleep, and every live call is bounded by asyncio.wait_for. Gated so it never breaks anyone's CI: skips unless MEMORY_API_KEY is in the process environment and the Ollama endpoint answers a 5s probe. New `e2e` marker, deliberately not `integration` (the root conftest ties that marker to Neo4j reachability). Teardown deletes every conversation the run created and asserts they are gone; NAMS_E2E_KEEP=1 leaves them for inspection in the web UI. --- Makefile | 8 +- pyproject.toml | 1 + tests/e2e/__init__.py | 1 + tests/e2e/test_strands_agent_nams_e2e.py | 424 +++++++++++++++++++++++ 4 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/__init__.py create mode 100644 tests/e2e/test_strands_agent_nams_e2e.py diff --git a/Makefile b/Makefile index 3a20283c..bbfd651d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install install-all install-dev lint format typecheck ty test test-unit test-integration test-integration-mcp test-e2e test-all test-docker test-ci test-no-docker test-quick test-file test-match test-aws test-nams-unit test-nams-integration test-nams-staging test-nams-sandbox test-nams-local test-nams coverage coverage-all coverage-ci coverage-mcp test-examples test-examples-quick test-examples-no-neo4j test-docs test-docs-syntax test-docs-build test-docs-links neo4j-start neo4j-stop neo4j-logs clean build publish docs docs-diagrams-list docs-diagrams-status docs-diagrams-missing docs-diagrams-manifest docs-diagrams-add-refs docs-diagrams-generate example-basic example-resolution example-langchain example-pydantic examples chat-agent-install chat-agent-backend chat-agent-frontend chat-agent ts-install ts-build ts-test ts-test-unit ts-test-integration ts-lint ts-docs ts-conformance ts-pack ts-clean ts-test-examples +.PHONY: help install install-all install-dev lint format typecheck ty test test-unit test-integration test-integration-mcp test-e2e test-strands-agent-e2e test-all test-docker test-ci test-no-docker test-quick test-file test-match test-aws test-nams-unit test-nams-integration test-nams-staging test-nams-sandbox test-nams-local test-nams coverage coverage-all coverage-ci coverage-mcp test-examples test-examples-quick test-examples-no-neo4j test-docs test-docs-syntax test-docs-build test-docs-links neo4j-start neo4j-stop neo4j-logs clean build publish docs docs-diagrams-list docs-diagrams-status docs-diagrams-missing docs-diagrams-manifest docs-diagrams-add-refs docs-diagrams-generate example-basic example-resolution example-langchain example-pydantic examples chat-agent-install chat-agent-backend chat-agent-frontend chat-agent ts-install ts-build ts-test ts-test-unit ts-test-integration ts-lint ts-docs ts-conformance ts-pack ts-clean ts-test-examples # Default target help: @@ -146,6 +146,12 @@ test-e2e: @echo "Running end-to-end MCP flow tests with testcontainers..." uv run pytest tests/integration/test_mcp_e2e.py -v --timeout=300 +# End-to-end: a real Strands Agent + Neo4jMemoryStore against live NAMS and a +# local Ollama. Skips cleanly unless MEMORY_API_KEY is in the *process* env and +# the Ollama endpoint answers. NAMS_E2E_KEEP=1 leaves the run's data in place. +test-strands-agent-e2e: + uv run --env-file .env pytest tests/e2e/test_strands_agent_nams_e2e.py -v -s --timeout=900 + # NAMS unit tests (respx-based, no Docker required) — v0.4 test-nams-unit: uv run pytest tests/unit/nams -v diff --git a/pyproject.toml b/pyproject.toml index 352acfa0..91c2c111 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -193,6 +193,7 @@ markers = [ "imports: marks tests as import validation (medium)", "slow: marks tests as slow (external link checks, full builds)", "aws: marks tests as AWS integration tests (require AWS credentials)", + "e2e: marks tests as end-to-end tests driving a real framework agent against live services (skip unless credentials + a local LLM are reachable)", ] filterwarnings = [ "ignore::DeprecationWarning", diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 00000000..efecf603 --- /dev/null +++ b/tests/e2e/__init__.py @@ -0,0 +1 @@ +"""End-to-end tests that drive a real framework agent against live services.""" diff --git a/tests/e2e/test_strands_agent_nams_e2e.py b/tests/e2e/test_strands_agent_nams_e2e.py new file mode 100644 index 00000000..7e13b8f0 --- /dev/null +++ b/tests/e2e/test_strands_agent_nams_e2e.py @@ -0,0 +1,424 @@ +"""End-to-end: a real Strands ``Agent`` + ``Neo4jMemoryStore`` against live NAMS. + +Why this file exists +==================== +Every other test of :class:`~neo4j_agent_memory.integrations.strands.memory_store.Neo4jMemoryStore` +drives the store *directly*, on one event loop. That is exactly the blind spot +that let a Critical through review: Strands' **synchronous** entry point runs +``Agent.__init__`` on one throwaway loop and every ``Agent.__call__`` on +another (``strands._async.run_async`` is ``asyncio.run`` in a worker thread), +so a store built from ``settings=`` connected its neo4j/httpx client on the +construction loop and then raised ``RuntimeError: ... attached to a different +loop`` from the first call. ``initialize()`` now rebinds an owned client when +the loop changes; nothing exercised it end-to-end until here. + +So this test constructs a real ``Agent``, calls it **twice** synchronously +(construction-loop -> call-loop, then call-loop -> call-loop) and then verifies +the memory actually landed in, and came back out of, hosted NAMS. + +A test, not an example +====================== +It needs per-run unique names, teardown that runs on assertion failure, and a +skip gate that keeps it out of everyone else's CI. pytest gives all three for +free; a script in ``examples/`` would hand-roll them and never be run. + +Running it +========== +Credentials must be in the *process* environment (``MemorySettings``' dotenv +source filters out keys that are not top-level model fields), so:: + + uv run --env-file .env pytest tests/e2e/test_strands_agent_nams_e2e.py -v -s + +Requires, or it skips cleanly: + +* ``MEMORY_API_KEY`` (plus optional ``MEMORY_ENDPOINT`` / ``MEMORY_WORKSPACE_ID``). +* A local Ollama answering on ``OLLAMA_BASE_URL`` (default + ``http://localhost:11434/v1``) serving a **tool-calling** model. + ``MemoryManager`` registers ``search_memory`` plus the store's graph tools, so + a model that rejects ``tools`` cannot drive this test at all. + +Environment knobs +================= +``OLLAMA_BASE_URL`` / ``OLLAMA_MODEL_ID`` + Point the test at a different local LLM. +``NAMS_E2E_KEEP=1`` + **Skip teardown** and leave this run's conversation in the workspace, for + inspecting it in the NAMS web UI. Unset (the default) deletes every + conversation the run created and asserts they are gone, so the committed + test stays well-behaved against a shared workspace. + +Two things observed while building this, so nobody re-derives them +================================================================= +*Teardown is only as complete as NAMS lets it be.* ``clear_session`` deletes +the conversation; the entities NAMS extracted *from* it survive in the +workspace. ``NamsLongTermMemory`` declares a ``DELETE /entities/{id}`` endpoint +spec but exposes no ``delete_entity()`` method, so there is no public route to +remove them. Hence the deliberately disposable, run-id-suffixed entity names. + +*The ``opik`` pytest plugin loads the repo-root ``.env`` into ``os.environ``.* +So under ``uv run pytest`` the credentials are present even without +``--env-file``, and the skip gate looks inert locally. To see it actually skip, +disable that plugin: ``uv run pytest ... -p no:opik``. In CI, where there is no +``.env``, the gate holds either way. +""" + +from __future__ import annotations + +import asyncio +import copy +import json +import logging +import os +import urllib.error +import urllib.request +import uuid +from collections.abc import AsyncGenerator, Iterator +from typing import Any + +import pytest + +pytest.importorskip("strands", reason="strands-agents not installed") + +from strands import Agent +from strands.memory import ( + ExtractionConfig, + InvocationTrigger, + MemoryEntry, + MemoryInjectionConfig, + MemoryManager, +) +from strands.models.openai import OpenAIModel +from strands.types.content import Message +from strands.types.streaming import StreamEvent +from strands.types.tools import ToolSpec + +from neo4j_agent_memory import MemoryClient +from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore, Neo4jMemoryStoreConfig +from neo4j_agent_memory.integrations.strands.config import ( + build_nams_settings, + resolve_nams_connection, +) +from neo4j_agent_memory.integrations.strands.memory_store import _STORE_KEY + +logger = logging.getLogger(__name__) + +# Not `integration`: the root conftest auto-skips that marker whenever Neo4j is +# unreachable, and this test wants NAMS + Ollama, not Neo4j. +pytestmark = pytest.mark.e2e + +#: OpenAI-compatible Ollama endpoint. Overridable so this is not hard-wired to a laptop. +OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434/v1") + +#: Ollama ignores the key but the OpenAI client requires a non-empty one. +OLLAMA_API_KEY = "ollama" + +#: Must emit real ``tool_calls``. Verified: ``qwen3.5:9b`` does; ``gemma3:12b`` +#: answers "gemma3:12b does not support tools" and cannot drive this test. +OLLAMA_MODEL_ID = os.environ.get("OLLAMA_MODEL_ID", "qwen3.5:9b") + +#: Leave this run's NAMS data in place instead of tearing it down. +KEEP_NAMS_DATA = os.environ.get("NAMS_E2E_KEEP", "").strip().lower() in {"1", "true", "yes"} + +#: Seconds to wait for NAMS' asynchronous, server-side extraction pipeline. +EXTRACTION_TIMEOUT = 180.0 + +#: Seconds any single live call may block, so a hung service cannot hang a dev box. +CALL_TIMEOUT = 240.0 + +#: One run's namespace. Store name is kept slug-safe so the store's tool prefix +#: equals it verbatim (see ``_store_tools._tool_prefix``) and the expected tool +#: name below needs no private helper to compute. +RUN_ID = uuid.uuid4().hex[:10] +STORE_NAME = f"e2e_{RUN_ID}" +USER_ID = f"e2e-user-{RUN_ID}" + +#: What the store derives its sink conversation's ``session_id`` / metadata tag +#: from (``Neo4jMemoryStore._sink_name``). Recomputed here so a failing run +#: prints something the owner can search the workspace for. +SINK_NAME = f"strands-memory-store/{USER_ID}/{STORE_NAME}" + +#: A token no other workspace data can contain, so recall assertions are about +#: *our* memory rather than a nearest-neighbour hit on someone else's. +TOKEN = f"Zorbium{RUN_ID.upper()}" + +#: Seeded via ``store.add()``. Recall is asserted against this rather than +#: against the model's own turn text: NAMS extracts server-side and +#: nearest-neighbour entity search would not let us assert deterministically on +#: whatever a 9B local model happened to say. +SEEDED_MEMORY = ( + f"{TOKEN} is the codename of the quarterly planning ritual " + f"at Contoso Robotics, run every March in Reykjavik." +) + + +# --------------------------------------------------------------------------- +# Skip gate +# --------------------------------------------------------------------------- + + +def _ollama_models() -> list[str] | None: + """Model names Ollama serves, or ``None`` when it does not answer. + + Short timeout on purpose: a contributor without a local LLM must get a + skip in a second, not a hang. + """ + tags_url = OLLAMA_BASE_URL.removesuffix("/v1").rstrip("/") + "/api/tags" + try: + with urllib.request.urlopen(tags_url, timeout=5) as response: # noqa: S310 - fixed localhost URL + payload = json.loads(response.read()) + except (urllib.error.URLError, OSError, ValueError, TimeoutError): + return None + models = payload.get("models") if isinstance(payload, dict) else None + return [str(entry.get("name")) for entry in (models or []) if isinstance(entry, dict)] + + +def _skip_reason() -> str | None: + """Why this test cannot run here, or ``None`` when it can.""" + if not os.environ.get("MEMORY_API_KEY"): + return ( + "MEMORY_API_KEY is not in the process environment. Run with " + "`uv run --env-file .env pytest ...` -- MemorySettings' dotenv source " + "drops non-field keys, so a bare .env is not enough." + ) + served = _ollama_models() + if served is None: + return f"No Ollama at {OLLAMA_BASE_URL} (checked /api/tags with a 5s timeout)." + if OLLAMA_MODEL_ID not in served: + return f"Ollama does not serve {OLLAMA_MODEL_ID!r}. Pull it or set OLLAMA_MODEL_ID." + return None + + +@pytest.fixture(scope="module", autouse=True) +def _gate() -> None: + reason = _skip_reason() + if reason: + pytest.skip(reason) + + +# --------------------------------------------------------------------------- +# NAMS helpers -- a client of our own, independent of the store's +# --------------------------------------------------------------------------- + + +def _nams_settings() -> Any: + endpoint, api_key = resolve_nams_connection() + return build_nams_settings(endpoint, api_key) + + +async def _our_conversations(client: MemoryClient) -> list[Any]: + """Conversations this run's store created, found by its sink metadata tag. + + Matches on ``RUN_ID`` rather than on ``SINK_NAME`` verbatim, so it still + finds the sink if the store's naming scheme changes -- both the store name + and the user id embed ``RUN_ID``. + """ + found = [] + for conversation in await client.short_term.list_conversations(limit=1000): + tag = (conversation.metadata or {}).get(_STORE_KEY) + if tag and RUN_ID in str(tag): + found.append(conversation) + return found + + +@pytest.fixture(scope="module") +def nams_teardown() -> Iterator[None]: + """Delete every conversation this run created, then prove it is gone. + + Module-scoped and ``finally``-guarded so it runs even when an assertion + fails mid-test -- the point is never to leave data in a shared workspace. + ``NAMS_E2E_KEEP=1`` opts out, for inspecting the run in the NAMS web UI. + """ + print(f"\n[run] id={RUN_ID} store={STORE_NAME} user_id={USER_ID} sink_name={SINK_NAME}") + try: + yield + finally: + + async def _cleanup() -> None: + async with MemoryClient(_nams_settings()) as client: + doomed = await _our_conversations(client) + ids = [str(conversation.id) for conversation in doomed] + print(f"\n[cleanup] conversations created by run {RUN_ID}: {ids}") + if KEEP_NAMS_DATA: + print("[cleanup] NAMS_E2E_KEEP=1 -- leaving the above in place.") + return + for conversation_id in ids: + print(f"[cleanup] clear_session({conversation_id})") + await client.short_term.clear_session(conversation_id) + remaining = await _our_conversations(client) + print(f"[cleanup] remaining after delete: {[str(c.id) for c in remaining]}") + assert remaining == [], f"cleanup left {len(remaining)} conversation(s) behind" + + asyncio.run(asyncio.wait_for(_cleanup(), timeout=CALL_TIMEOUT)) + + +# --------------------------------------------------------------------------- +# A model that records what it was actually asked -- for the injection assertion +# --------------------------------------------------------------------------- + + +class RecordingModel(OpenAIModel): + """``OpenAIModel`` that keeps a copy of every message list it is handed. + + Subclassing and overriding the public ``Model.stream`` is the supported + extension point, so the injection assertion reads what the model really + received rather than reaching into ``MemoryManager``'s internals. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.received: list[list[Message]] = [] + + async def stream( + self, + messages: list[Message], + tool_specs: list[ToolSpec] | None = None, + system_prompt: str | None = None, + **kwargs: Any, + ) -> AsyncGenerator[StreamEvent, None]: + self.received.append(copy.deepcopy(messages)) + async for event in super().stream(messages, tool_specs, system_prompt, **kwargs): + yield event + + def all_text(self) -> str: + """Every text block from every recorded call, concatenated.""" + chunks: list[str] = [] + for messages in self.received: + for message in messages: + for block in message.get("content") or []: + text = block.get("text") if isinstance(block, dict) else None + if text: + chunks.append(str(text)) + return "\n".join(chunks) + + +# --------------------------------------------------------------------------- +# The test +# --------------------------------------------------------------------------- + + +async def _seed_and_await_extraction(store: Neo4jMemoryStore) -> str: + """Write the seed memory, then wait until NAMS has actually extracted it. + + Ordering matters and is the whole reason this is a separate step: + ``MemoryManager``'s injection *fails open* -- when the store's search comes + back empty it silently injects nothing. NAMS extracts asynchronously, so a + seed written immediately before the first turn is not yet searchable and the + injection assertion would fail for a reason that is not a defect. Await the + pipeline first; only then is "did the ```` block reach the model?" a + meaningful question. + + Returns the sink conversation id. + """ + written = await store.add(SEEDED_MEMORY) + print(f"[seed] store.add -> {written}") + + async with MemoryClient(_nams_settings()) as client: + conversations = await _our_conversations(client) + assert len(conversations) == 1, ( + f"expected exactly one sink conversation for run {RUN_ID}, got {len(conversations)}" + ) + sink_id = str(conversations[0].id) + print(f"[seed] sink conversation id = {sink_id}") + + settled = await client.long_term.wait_for_extraction( + session_id=sink_id, timeout=EXTRACTION_TIMEOUT, interval=2.0 + ) + status = await client.short_term.get_extraction_status(sink_id) + print(f"[seed] extraction settled={settled} status={status.summary}") + assert settled, f"NAMS extraction did not settle within {EXTRACTION_TIMEOUT}s" + + def _has_token(entities: list[Any]) -> bool: + return any(TOKEN.lower() in (entity.name or "").lower() for entity in entities) + + searchable = await client.long_term.wait_for_extraction( + query=TOKEN, predicate=_has_token, timeout=EXTRACTION_TIMEOUT, interval=3.0 + ) + print(f"[seed] {TOKEN} searchable={searchable}") + assert searchable, f"{TOKEN} never became searchable in NAMS" + + entities = await client.long_term.search_entities(TOKEN, limit=10) + print(f"[seed] entities matching {TOKEN}: {[e.name for e in entities]}") + + return sink_id + + +async def _verify_recall(store: Neo4jMemoryStore, sink_id: str) -> None: + """Assertions 2 and 3: the turns landed in the sink, and recall works.""" + async with MemoryClient(_nams_settings()) as client: + conversation = await client.short_term.get_conversation(sink_id) + texts = [message.content for message in conversation.messages] + print(f"[verify] {len(texts)} message(s) in the sink") + for text in texts: + print(f"[verify] {text[:200]}") + assert any(SEEDED_MEMORY in text for text in texts), "store.add() text missing from sink" + assert any(TOKEN in text and SEEDED_MEMORY not in text for text in texts), ( + "no agent turn text in the sink -- extraction/add_messages never ran" + ) + + entries: list[MemoryEntry] = await store.search(TOKEN) + print(f"[verify] store.search({TOKEN}) -> {len(entries)} entr(y/ies)") + for entry in entries: + print(f"[verify] {entry.content} :: {entry.metadata}") + assert entries, "store.search returned nothing" + assert any(TOKEN.lower() in entry.content.lower() for entry in entries), ( + "store.search returned entries, but none derived from the seeded memory" + ) + + +def test_sync_agent_drives_nams_backed_memory_store(nams_teardown: None) -> None: + """The whole product path, on the synchronous entry point that broke. + + Deliberately a **sync** test: ``asyncio_mode = "auto"`` would otherwise run + the body inside a loop, and it is precisely Strands' sync entry point -- + a fresh loop per call -- that this test exists to cover. Each async + verification step gets its own ``asyncio.run``, which incidentally + exercises the owned-client rebind once more. + """ + store = Neo4jMemoryStore( + Neo4jMemoryStoreConfig( + name=STORE_NAME, + settings=_nams_settings(), + user_id=USER_ID, + # Server-side extraction: the store implements add_messages, so the + # manager hands it the filtered turn directly -- no model call. + extraction=ExtractionConfig(trigger=InvocationTrigger()), + max_search_results=5, + ) + ) + model = RecordingModel( + client_args={"base_url": OLLAMA_BASE_URL, "api_key": OLLAMA_API_KEY}, + model_id=OLLAMA_MODEL_ID, + ) + manager = MemoryManager(stores=[store], injection=MemoryInjectionConfig(max_entries=5)) + + sink_id = asyncio.run( + asyncio.wait_for(_seed_and_await_extraction(store), timeout=EXTRACTION_TIMEOUT * 2 + 60) + ) + + # --- 1. The synchronous path ------------------------------------------- + # Agent.__init__ initializes the store on one loop... + agent = Agent(model=model, memory_manager=manager) + print("[agent] constructed") + + # ...and each __call__ drives it from another. Two calls: construction-loop + # -> call-loop, then call-loop -> call-loop. + first = agent(f"In one sentence, what do you know about {TOKEN}?") + print(f"[turn 1] {str(first)[:400]}") + second = agent("And where does it happen? One short sentence.") + print(f"[turn 2] {str(second)[:400]}") + + # --- 4. Namespaced tools ------------------------------------------------ + print(f"[tools] {sorted(agent.tool_names)}") + assert f"{STORE_NAME}_get_entity_graph" in agent.tool_names + # NAMS has no preferences endpoint, so the store must not ship this tool. + assert f"{STORE_NAME}_get_user_preferences" not in agent.tool_names + assert "search_memory" in agent.tool_names + + # --- 5. Injection reached the model ------------------------------------ + sent = model.all_text() + assert "" in sent, "MemoryManager's default block never reached the model" + assert TOKEN in sent, f"the seeded memory ({TOKEN}) was not among the injected entries" + + # --- 2 & 3. It landed in NAMS, and comes back out ---------------------- + asyncio.run(asyncio.wait_for(_verify_recall(store, sink_id), timeout=CALL_TIMEOUT)) + asyncio.run(store.aclose()) From 34b8c4e187f5df05ca8b228451bc04d33494bc9c Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 20 Aug 2026 17:09:14 +0200 Subject: [PATCH 36/39] test(nams): gate live-NAMS tests on a throwaway workspace; stop resolver noise The SDK cannot clean up what these two tests create. clear_session deletes a conversation but not the entities NAMS extracted from it, and NamsLongTermMemory declares _SPEC_DELETE_ENTITY while exposing no delete_entity() -- so entities, and the pending fuzzy-merge candidates the resolver files against them, accumulate permanently in whatever workspace the credentials point at. A live run is a one-way write. Our own runs already left the owner a review backlog to clear by hand. So both tests now refuse to run unless NAMS_E2E_WORKSPACE_ID names a workspace that is not MEMORY_WORKSPACE_ID (nor its NAMS_SANDBOX_WORKSPACE_ID alias): unset and equal each skip with a reason that states why the variable is separate. The gate is new tests/nams_live.py, called at module scope before every other gate -- nams_credentials is session-scoped and would otherwise report a missing key first, hiding whether the gate is wired at all. Both docstrings now record that the opik pytest plugin loads .env into os.environ regardless of the shell, so skips must be verified with `-p no:opik`; two agents lost an hour to that. The workspace is wired in explicitly rather than inherited: the integration test overrides conftest's nams_config fixture, and build_nams_settings gains a keyword-only workspace_id forwarded to NamsConfig -- the only missing link, since without it MemorySettings falls back to MEMORY_WORKSPACE_ID and the gate would be theatre. Default None keeps every existing caller unchanged. Second fix: the e2e fixture's entity name was one stem plus a hex run suffix (Zorbium89980F486C, Zorbium868C72705A, ...). Those are ~85% similar to each other, so every run handed the resolver a fresh merge candidate against every previous run. TOKEN is now one word drawn from a 128-word pool of pronounceable nonsense, built by greedy rejection sampling that admitted a candidate only at <= 55 against every incumbent under all four of rapidfuzz's ratio, WRatio, partial_ratio and token_sort_ratio. Measured worst case over all 8128 pairs: 54.5, with no word a substring of another. Distinct runs cannot reach the threshold; identical draws (1 in 128) are exact matches that merge and queue nothing. One word, not two concatenated: a shared 8-char half measured 81.5 under partial_ratio, and a three-syllable scheme measured 90.9 -- only whole-word distinctness holds substring-flavoured scorers down. RUN_ID still namespaces the store name, user id and sink metadata, so runs stay traceable; the run banner prints the codename since it is no longer derivable from RUN_ID. Third: the aws-strands guide claimed user_id scopes writes via ":User edges", which is bolt-only. NAMS stores tenancy as a plain userId property on the conversation -- no node, no edge -- and filters the conversation listing on it; _link_user_to_conversation exists only in memory/short_term.py. Guide and design spec now state one clause per backend. --- .../how-to/integrations/aws-strands.adoc | 9 +- .../2026-08-19-strands-memory-store-design.md | 2 +- .../integrations/strands/config.py | 15 +- tests/e2e/test_strands_agent_nams_e2e.py | 91 ++++++--- .../nams/test_strands_memory_store.py | 45 ++++- tests/nams_live.py | 181 ++++++++++++++++++ 6 files changed, 314 insertions(+), 29 deletions(-) create mode 100644 tests/nams_live.py diff --git a/docs/modules/ROOT/pages/how-to/integrations/aws-strands.adoc b/docs/modules/ROOT/pages/how-to/integrations/aws-strands.adoc index c8e39cb5..1d68bbf0 100644 --- a/docs/modules/ROOT/pages/how-to/integrations/aws-strands.adoc +++ b/docs/modules/ROOT/pages/how-to/integrations/aws-strands.adoc @@ -197,9 +197,12 @@ attribute, not a dict key. Pass exactly one of `client` (a pre-connected | `user_id` | `None` -| Scopes *writes* to one tenant (the `:User` edges on messages and - preferences) and gates the `get_user_preferences` tool. It does *not* - narrow `search()` — the long-term search APIs take no user filter. +| Scopes *writes* to one tenant — on `bolt`, as a + `(:User)-[:HAS_CONVERSATION]->(:Conversation)` edge plus a denormalized + property; on NAMS, as a plain `userId` property on the conversation (no + `:User` node, no edge) that the conversation listing filters on. Also gates + the `get_user_preferences` tool. It does *not* narrow `search()` — the + long-term search APIs take no user filter. | `include_entities` / `include_preferences` / `include_facts` | `True` diff --git a/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md b/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md index 70217d9d..982664f2 100644 --- a/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md +++ b/docs/superpowers/specs/2026-08-19-strands-memory-store-design.md @@ -127,7 +127,7 @@ protocol requires as store attributes): |---|---|---| | `client` \| `settings` | — | pre-connected `MemoryClient`, or settings the store builds one from | | `conversation_id` | minted in `initialize()` | write sink target | -| `user_id` | `None` | scopes *writes* in multi-tenant mode, and gates the `get_user_preferences` tool. Reads are not narrowed — the long-term search APIs take no user filter | +| `user_id` | `None` | scopes *writes* to one tenant — on bolt a `(:User)-[:HAS_CONVERSATION]->(:Conversation)` edge plus a denormalized property (`memory/short_term.py::_link_user_to_conversation`), on NAMS a plain `userId` property on the conversation, no node and no edge (`nams/short_term.py`) — and gates the `get_user_preferences` tool. Reads are not narrowed — the long-term search APIs take no user filter | | `include_entities` | `True` | search fan-out | | `include_preferences` | `True` | auto-gated off on NAMS | | `include_facts` | `True` | auto-gated off on NAMS | diff --git a/src/neo4j_agent_memory/integrations/strands/config.py b/src/neo4j_agent_memory/integrations/strands/config.py index 45f31dcd..aa07ffe2 100644 --- a/src/neo4j_agent_memory/integrations/strands/config.py +++ b/src/neo4j_agent_memory/integrations/strands/config.py @@ -182,9 +182,21 @@ def build_nams_settings( transport_mode: TransportMode = "auto", *, validate_on_connect: bool = False, + workspace_id: str | None = None, ) -> MemorySettings: """Build NAMS-backed MemorySettings (validate_on_connect off by default — - Strands drives short synchronous bursts; skipping the probe saves a round-trip).""" + Strands drives short synchronous bursts; skipping the probe saves a round-trip). + + Args: + endpoint: NAMS base URL. + api_key: NAMS API key. + transport_mode: MCP / REST transport selection. + validate_on_connect: Whether to probe the service on connect. + workspace_id: Explicit NAMS workspace, transmitted as ``X-Workspace-Id``. + Left ``None``, ``MemorySettings`` falls back to ``MEMORY_WORKSPACE_ID`` + from the environment — pass it when the caller must not inherit + whatever workspace the ambient environment names. + """ from pydantic import SecretStr from neo4j_agent_memory import MemorySettings, NamsConfig @@ -196,6 +208,7 @@ def build_nams_settings( api_key=SecretStr(api_key), validate_on_connect=validate_on_connect, transport_mode=transport_mode, + workspace_id=workspace_id, ), ) diff --git a/tests/e2e/test_strands_agent_nams_e2e.py b/tests/e2e/test_strands_agent_nams_e2e.py index 7e13b8f0..7d6581fd 100644 --- a/tests/e2e/test_strands_agent_nams_e2e.py +++ b/tests/e2e/test_strands_agent_nams_e2e.py @@ -31,12 +31,26 @@ Requires, or it skips cleanly: -* ``MEMORY_API_KEY`` (plus optional ``MEMORY_ENDPOINT`` / ``MEMORY_WORKSPACE_ID``). +* ``NAMS_E2E_WORKSPACE_ID`` — a **throwaway** workspace, necessarily different + from ``MEMORY_WORKSPACE_ID``. See "Why a throwaway workspace" below. +* ``MEMORY_API_KEY`` (plus optional ``MEMORY_ENDPOINT``). * A local Ollama answering on ``OLLAMA_BASE_URL`` (default ``http://localhost:11434/v1``) serving a **tool-calling** model. ``MemoryManager`` registers ``search_memory`` plus the store's graph tools, so a model that rejects ``tools`` cannot drive this test at all. +Why a throwaway workspace +========================= +Teardown is only as complete as NAMS lets it be. ``clear_session`` deletes the +conversation; the entities NAMS extracted *from* it survive in the workspace. +``NamsLongTermMemory`` declares a ``DELETE /entities/{id}`` endpoint spec +(``_SPEC_DELETE_ENTITY``) but exposes no ``delete_entity()`` method, so there is +no public route to remove them — nor the pending fuzzy-merge candidates the +resolver files against them. A run is therefore a **one-way write**, which is +why this test refuses to target a workspace anybody works in. The gate lives in +``tests/nams_live.py``; it is checked before the credential and Ollama gates so +that the reason a run did not happen is never ambiguous. + Environment knobs ================= ``OLLAMA_BASE_URL`` / ``OLLAMA_MODEL_ID`` @@ -44,22 +58,20 @@ ``NAMS_E2E_KEEP=1`` **Skip teardown** and leave this run's conversation in the workspace, for inspecting it in the NAMS web UI. Unset (the default) deletes every - conversation the run created and asserts they are gone, so the committed - test stays well-behaved against a shared workspace. - -Two things observed while building this, so nobody re-derives them -================================================================= -*Teardown is only as complete as NAMS lets it be.* ``clear_session`` deletes -the conversation; the entities NAMS extracted *from* it survive in the -workspace. ``NamsLongTermMemory`` declares a ``DELETE /entities/{id}`` endpoint -spec but exposes no ``delete_entity()`` method, so there is no public route to -remove them. Hence the deliberately disposable, run-id-suffixed entity names. - -*The ``opik`` pytest plugin loads the repo-root ``.env`` into ``os.environ``.* -So under ``uv run pytest`` the credentials are present even without -``--env-file``, and the skip gate looks inert locally. To see it actually skip, -disable that plugin: ``uv run pytest ... -p no:opik``. In CI, where there is no -``.env``, the gate holds either way. + conversation the run created and asserts they are gone — as much cleanup as + the SDK can do. + +The one thing that makes the gate look broken +============================================= +*The ``opik`` pytest plugin loads the repo-root ``.env`` into ``os.environ``,* +whatever the shell says. So under ``uv run pytest`` the credentials *and* +``MEMORY_WORKSPACE_ID`` are present even without ``--env-file``, and the skip +gates look inert locally. To see them actually skip, disable that plugin:: + + uv run pytest tests/e2e/test_strands_agent_nams_e2e.py -p no:opik + +Two agents lost an hour to this before it was written here. In CI, where there +is no ``.env``, the gates hold either way. """ from __future__ import annotations @@ -77,6 +89,13 @@ import pytest +from tests.nams_live import skip_without_throwaway_workspace, unique_codename + +#: The only workspace this run is allowed to write to. Resolved before every +#: other gate so a run that would have landed in somebody's working workspace +#: reports *that*, rather than whichever credential it also happens to lack. +THROWAWAY_WORKSPACE_ID = skip_without_throwaway_workspace() + pytest.importorskip("strands", reason="strands-agents not installed") from strands import Agent @@ -137,9 +156,19 @@ #: prints something the owner can search the workspace for. SINK_NAME = f"strands-memory-store/{USER_ID}/{STORE_NAME}" -#: A token no other workspace data can contain, so recall assertions are about -#: *our* memory rather than a nearest-neighbour hit on someone else's. -TOKEN = f"Zorbium{RUN_ID.upper()}" +#: The fixture entity's name: pronounceable nonsense no other workspace data can +#: contain, so recall assertions are about *our* memory rather than a +#: nearest-neighbour hit on someone else's. +#: +#: Drawn from ``tests.nams_live``'s mutually-distant codename pool and +#: deliberately **not** ``RUN_ID``-suffixed. A shared stem plus a hex suffix +#: (the first cut: ``Zorbium89980F486C``, ``Zorbium868C72705A``, …) makes every +#: run ~85% similar to every previous one, so NAMS' resolver filed a pending +#: fuzzy-merge candidate per pair — a review backlog that, per the note above, +#: nothing in the SDK can clear. Codenames are <= 54.5% similar to each other, +#: well under the threshold. ``RUN_ID`` still namespaces the store name and user +#: id below (sink naming, not entity data), so runs stay traceable. +TOKEN = unique_codename() #: Seeded via ``store.add()``. Recall is asserted against this rather than #: against the model's own turn text: NAMS extracts server-side and @@ -173,7 +202,11 @@ def _ollama_models() -> list[str] | None: def _skip_reason() -> str | None: - """Why this test cannot run here, or ``None`` when it can.""" + """Why this test cannot run here, or ``None`` when it can. + + The throwaway-workspace gate is *not* checked here: it runs at module scope + (see ``THROWAWAY_WORKSPACE_ID``) so it wins over these reachability checks. + """ if not os.environ.get("MEMORY_API_KEY"): return ( "MEMORY_API_KEY is not in the process environment. Run with " @@ -201,8 +234,15 @@ def _gate() -> None: def _nams_settings() -> Any: + """NAMS settings pinned to the throwaway workspace. + + ``workspace_id`` is passed explicitly (the client transmits it as + ``X-Workspace-Id``) rather than left to ``MemorySettings``' fallback onto + ``MEMORY_WORKSPACE_ID`` — which is exactly the working workspace this test + must not touch. + """ endpoint, api_key = resolve_nams_connection() - return build_nams_settings(endpoint, api_key) + return build_nams_settings(endpoint, api_key, workspace_id=THROWAWAY_WORKSPACE_ID) async def _our_conversations(client: MemoryClient) -> list[Any]: @@ -228,7 +268,12 @@ def nams_teardown() -> Iterator[None]: fails mid-test -- the point is never to leave data in a shared workspace. ``NAMS_E2E_KEEP=1`` opts out, for inspecting the run in the NAMS web UI. """ - print(f"\n[run] id={RUN_ID} store={STORE_NAME} user_id={USER_ID} sink_name={SINK_NAME}") + # TOKEN is printed because it is no longer derivable from RUN_ID: it is the + # only handle on the entities this run leaves behind in the workspace. + print( + f"\n[run] id={RUN_ID} store={STORE_NAME} user_id={USER_ID} " + f"sink_name={SINK_NAME} codename={TOKEN}" + ) try: yield finally: diff --git a/tests/integration/nams/test_strands_memory_store.py b/tests/integration/nams/test_strands_memory_store.py index 73be447b..057f02f2 100644 --- a/tests/integration/nams/test_strands_memory_store.py +++ b/tests/integration/nams/test_strands_memory_store.py @@ -10,6 +10,19 @@ The spec's Testing table (``docs/superpowers/specs/2026-08-19-strands-memory-store-design.md``) promised this suite as "key-gated NAMS ... not yet implemented" — this closes that gap. + +Gated on a throwaway workspace +============================== +This test writes to live NAMS, and the SDK cannot fully undo it: +``clear_session`` deletes the sink conversation but not the entities NAMS +extracted from it, and there is no ``delete_entity()``. So it refuses to run +unless ``NAMS_E2E_WORKSPACE_ID`` names a workspace that is *not* +``MEMORY_WORKSPACE_ID`` — see ``tests/nams_live.py`` for the full reasoning. + +To watch that skip actually happen, pass ``-p no:opik``: the ``opik`` pytest +plugin loads the repo-root ``.env`` into ``os.environ`` whatever the shell says, +so under a plain ``uv run pytest`` the workspace variables are present and the +gate looks inert. Two agents lost an hour to that before it was written down. """ from __future__ import annotations @@ -18,16 +31,46 @@ import pytest import pytest_asyncio +from pydantic import SecretStr + +from tests.nams_live import skip_without_throwaway_workspace + +# First gate, before the credential and dependency gates: a run that would land +# in somebody's working workspace must report *that* as the reason it did not +# happen, not a missing key it also happens to lack. +THROWAWAY_WORKSPACE_ID = skip_without_throwaway_workspace() pytest.importorskip("strands", reason="strands-agents not installed") -from neo4j_agent_memory import MemoryClient +from neo4j_agent_memory import MemoryClient, NamsConfig from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore, Neo4jMemoryStoreConfig from neo4j_agent_memory.integrations.strands.memory_store import _STORE_KEY pytestmark = pytest.mark.integration +@pytest.fixture +def nams_config(nams_credentials: tuple[str, str, str | None]) -> NamsConfig: + """Overrides ``conftest.py``'s fixture to pin the **throwaway** workspace. + + The conftest default resolves the workspace from ``MEMORY_WORKSPACE_ID`` / + ``NAMS_SANDBOX_WORKSPACE_ID``. This module must never write there, so the + id from ``NAMS_E2E_WORKSPACE_ID`` is wired into ``NamsConfig`` explicitly + (the client transmits it as ``X-Workspace-Id``) rather than left to + whatever the ambient environment resolves to. + """ + endpoint, api_key, _ = nams_credentials + return NamsConfig( + endpoint=endpoint, + api_key=SecretStr(api_key), + workspace_id=THROWAWAY_WORKSPACE_ID, + validate_on_connect=False, + max_retries=2, + retry_backoff_seconds=0.5, + timeout=20.0, + ) + + @pytest_asyncio.fixture async def _sink_cleanup(nams_client: MemoryClient) -> AsyncIterator[list[str]]: """Tracks conversation ids this test resolves/creates; best-effort teardown. diff --git a/tests/nams_live.py b/tests/nams_live.py new file mode 100644 index 00000000..05033033 --- /dev/null +++ b/tests/nams_live.py @@ -0,0 +1,181 @@ +"""Helpers for tests that write to the **live** hosted NAMS service. + +Why a dedicated throwaway workspace is mandatory +================================================ +The SDK cannot undo what these tests create. ``clear_session`` deletes a +conversation but *not* the entities NAMS extracted from it, and +``NamsLongTermMemory`` declares a ``DELETE /entities/{id}`` endpoint spec +(``_SPEC_DELETE_ENTITY``) while exposing no ``delete_entity()`` method — so +there is no public route to remove an entity. Entities, and the resolver's +pending fuzzy-merge candidates, therefore accumulate **permanently** in +whatever workspace the credentials happen to point at. A live run is a one-way +write, and every repeat run grows a review backlog somebody has to clear by +hand in the NAMS UI. + +So live-NAMS tests gate on ``NAMS_E2E_WORKSPACE_ID``: a workspace that exists +only to be polluted. It is deliberately a *different* variable from +``MEMORY_WORKSPACE_ID`` — the one a developer's ``.env`` already points at +their working workspace — so a working workspace can never become the target +by ambient default, and the gate refuses to run when the two are equal. + +Verifying the gate locally +========================== +The ``opik`` pytest plugin loads the repo-root ``.env`` into ``os.environ`` +whatever the shell says, so under a plain ``uv run pytest`` these variables are +present and the gate looks inert. Disable that plugin to see it fire:: + + uv run pytest -p no:opik + +Both prior attempts at this gate lost time to that. In CI, where there is no +``.env``, the gate holds either way. + +Entity names that cannot pile up in the resolver's review queue +=============================================================== +``unique_codename`` exists because the first cut of the e2e fixture built its +entity name from one stem plus a hex run suffix (``Zorbium89980F486C``, +``Zorbium868C72705A``, ``ZorbiumEEEF8D7F7E``). Those are ~85% similar to each +other, so every run handed NAMS' resolver a fresh pending fuzzy-merge candidate +against every previous run's entity — the exact backlog no SDK call can clear. +See ``_CODENAMES`` for the replacement and its measured similarity bound. +""" + +from __future__ import annotations + +import os +import random + +import pytest + +#: The throwaway workspace these tests are allowed to write to. +E2E_WORKSPACE_ENV = "NAMS_E2E_WORKSPACE_ID" + +#: Variables that name a workspace somebody actually works in; the throwaway +#: one must not equal any of them. ``NAMS_SANDBOX_WORKSPACE_ID`` is the +#: integration suite's alias for the same thing (``integration/nams/conftest.py``). +WORKING_WORKSPACE_ENVS = ("MEMORY_WORKSPACE_ID", "NAMS_SANDBOX_WORKSPACE_ID") + +#: Stated in both skip reasons: the gate is not a style preference. +_WHY = ( + "these tests leave behind entities -- and the pending fuzzy-merge " + "candidates NAMS' resolver queues for them -- that cannot be deleted " + "through the SDK (clear_session removes the conversation only; " + "NamsLongTermMemory exposes no delete_entity())" +) + +#: Repeated in both reasons because it is the single thing that makes this gate +#: look broken when you try to verify it (see the module docstring). +_HOW_TO_VERIFY = "Verify this skip with `-p no:opik` -- the opik plugin loads .env into os.environ." + + +def throwaway_workspace_id() -> str | None: + """The configured throwaway workspace id, or ``None`` when unset/blank.""" + return os.environ.get(E2E_WORKSPACE_ENV, "").strip() or None + + +def throwaway_workspace_skip_reason() -> str | None: + """Why a live-NAMS test must not run here, or ``None`` when it may. + + Two refusals, never a silent proceed: + + * ``NAMS_E2E_WORKSPACE_ID`` unset — no throwaway workspace was nominated. + * it equals a working-workspace variable — the nominated workspace is one + somebody uses. + """ + workspace = throwaway_workspace_id() + if workspace is None: + return ( + f"{E2E_WORKSPACE_ENV} is not set. Live-NAMS tests run only against a " + f"dedicated throwaway workspace, because {_WHY}. It is a separate " + f"variable from {WORKING_WORKSPACE_ENVS[0]} so a working workspace " + f"cannot become the target by ambient default. {_HOW_TO_VERIFY}" + ) + for name in WORKING_WORKSPACE_ENVS: + if (os.environ.get(name, "").strip() or None) == workspace: + return ( + f"{E2E_WORKSPACE_ENV} equals {name}: the throwaway workspace must " + f"not be the working one, because {_WHY}. Point " + f"{E2E_WORKSPACE_ENV} at a separate, disposable workspace. " + f"{_HOW_TO_VERIFY}" + ) + return None + + +def skip_without_throwaway_workspace() -> str: + """Skip the calling module unless a throwaway workspace is configured. + + Call at **module scope, before every other gate**, so this safety check + wins over a credential or service-reachability skip: otherwise a missing + ``MEMORY_API_KEY`` reports first and hides whether the workspace gate is + even wired up. + + Returns: + The throwaway workspace id, to pass explicitly into ``NamsConfig`` / + ``build_nams_settings`` rather than letting ``MemorySettings`` pick one + up from the ambient environment. + """ + reason = throwaway_workspace_skip_reason() + if reason is not None: + pytest.skip(reason, allow_module_level=True) + workspace = throwaway_workspace_id() + assert workspace is not None, "throwaway_workspace_skip_reason() passed with no workspace" + return workspace + + +# --------------------------------------------------------------------------- +# Per-run entity codenames +# --------------------------------------------------------------------------- + +#: Pronounceable nonsense words, one drawn per live run to name that run's +#: fixture entity. Two properties matter, and neither survives the +#: stem-plus-suffix scheme this replaces: +#: +#: * **Mutually distant.** Built by greedy rejection sampling that admitted a +#: candidate only when it scored <= 55 against every word already in the pool +#: under *all four* of ``rapidfuzz``'s ``ratio``, ``WRatio``, +#: ``partial_ratio`` and ``token_sort_ratio``. Measured worst case over all +#: 8128 pairs: **54.5** (``Zuczuto`` / ``Fuzocal``), far below the 85% that +#: made NAMS' resolver queue a merge candidate. No word is a prefix, suffix or +#: substring of another, so substring-flavoured scorers cannot inflate a pair +#: either -- which is why a codename is one word and not two concatenated +#: (concatenating two pool words shares a whole half verbatim and measured +#: 81.5 under ``partial_ratio``). +#: * **Not real words.** Nothing here can collide with, or be mistaken for, +#: genuine entities already in a workspace, so a recall assertion on a +#: codename is an assertion about *this run's* memory. +#: +#: Two runs either draw different codenames (<= 54.5% similar, no candidate) or +#: the same one (1 in 128), which is an *exact* match: NAMS resolves it to the +#: one existing entity and queues nothing. Runs stay individually traceable +#: regardless, because the store name, user id and sink metadata still carry the +#: hex run id. +# fmt: off +_CODENAMES = ( + "Daplokid", "Dudekad", "Zikaken", "Ripolisi", "Kunmima", "Gepdimiz", "Zasovor", "Memfapol", + "Feksenoc", "Puftisom", "Zokfeto", "Noravot", "Vabzebu", "Defkitek", "Fomubig", "Sicveru", + "Sovegof", "Fupmodo", "Vavezin", "Tebobizo", "Kocgure", "Dafeduz", "Rumzabem", "Filapor", + "Bibrigek", "Makmimiv", "Bagpavoc", "Gugrasa", "Mavzubef", "Nenazem", "Sarezolu", "Pakuceko", + "Sikumesu", "Tavtalac", "Tafbekog", "Gabiduko", "Gagudili", "Sotbokun", "Pacabag", "Gecomef", + "Mokobus", "Govirul", "Bevuzuc", "Tuczibaz", "Genutiga", "Lukzesek", "Numucipi", "Lusloris", + "Gobofali", "Rozgivuk", "Gupeface", "Casapugu", "Celucup", "Tunlupom", "Dimgufaf", "Pekivif", + "Bacukaco", "Nuvlesab", "Dulnule", "Rituraz", "Dismerac", "Ziscepe", "Bilodozu", "Zuczuto", + "Karitavu", "Fozuteni", "Vurkici", "Soscoba", "Cifetem", "Volzafiz", "Gamakut", "Lafviro", + "Pundofon", "Mitzidef", "Modbafol", "Tibinir", "Bedetes", "Zitibimo", "Nizufusu", "Batonaru", + "Carfomev", "Kinapud", "Gozfugob", "Taksupaz", "Ledilas", "Nisrefos", "Piraseg", "Dibolog", + "Nedzapit", "Muvalol", "Lugicol", "Murlagiv", "Zekgosuv", "Renonog", "Domfele", "Lureromo", + "Refveta", "Puvopegu", "Cageleba", "Fuvumic", "Nipkecus", "Nakifak", "Todvesic", "Temosos", + "Pezdadar", "Gocotud", "Cigidup", "Vunpadi", "Rovpare", "Mefgukug", "Kucrukuz", "Kegzode", + "Cisivabo", "Bungukot", "Cogafav", "Rupetib", "Mupsilum", "Mogpogem", "Lektazug", "Fiblusik", + "Zuvekilu", "Bicmanok", "Fuzocal", "Tiklelo", "Lufufec", "Subivane", "Valnalun", "Fasbaciv", +) +# fmt: on + + +def unique_codename() -> str: + """One run's fixture entity name: a codename drawn from ``_CODENAMES``. + + Deliberately *not* a shared stem plus a per-run suffix. Suffixed names are + ~85% similar to each other, which is precisely what makes NAMS' entity + resolver file a pending fuzzy-merge candidate for every pair -- an + ever-growing review backlog in a workspace the SDK cannot clean up. + """ + return random.SystemRandom().choice(_CODENAMES) From 6bbd91172b3f1d0ae0188441b71c237a259c2f9d Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 20 Aug 2026 17:14:21 +0200 Subject: [PATCH 37/39] docs: changelog entry for build_nams_settings workspace_id --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec46ddad..033ed1ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 replacing them in the agent's registry, and `max_search_results` caps the *total* rows per `search()` — shared across entities, preferences and facts so no kind is crowded out. +- **`build_nams_settings(..., workspace_id=)`** — optional explicit workspace for the + Strands NAMS helpers, instead of only the ambient `MEMORY_WORKSPACE_ID`. - **Strands SessionManager** (`Neo4jSessionManager`) — automatic conversation persistence/restore for AWS Strands agents via `Agent(session_manager=...)`, backed by any `MemoryClient` (bolt or NAMS). Includes opt-in long-term From 7ddadbac1e4225fdecd56ffa92043ed34d6d50a1 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 20 Aug 2026 17:30:07 +0200 Subject: [PATCH 38/39] test(strands): drop the hardware-specific live-NAMS e2e test The Strands agent e2e test required a local Ollama serving a tool-calling model and a dedicated throwaway NAMS workspace -- runnable on one machine, never in CI. Removes it with tests/nams_live.py, the e2e pytest marker, the test-strands-agent-e2e target, and the build_nams_settings workspace_id kwarg that had no other caller. tests/integration/nams/test_strands_memory_store.py stays, gated by the existing NAMS conftest: it creates conversations only, so clear_session teardown is complete and it needs no separate workspace. --- CHANGELOG.md | 2 - Makefile | 8 +- pyproject.toml | 1 - .../integrations/strands/config.py | 6 - src/neo4j_agent_memory/nams/short_term.py | 15 +- tests/e2e/__init__.py | 1 - tests/e2e/test_strands_agent_nams_e2e.py | 469 ------------------ .../nams/test_strands_memory_store.py | 79 +-- tests/nams_live.py | 181 ------- tests/unit/integrations/strands_fakes.py | 9 +- 10 files changed, 25 insertions(+), 746 deletions(-) delete mode 100644 tests/e2e/__init__.py delete mode 100644 tests/e2e/test_strands_agent_nams_e2e.py delete mode 100644 tests/nams_live.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 033ed1ef..ec46ddad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,8 +22,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 replacing them in the agent's registry, and `max_search_results` caps the *total* rows per `search()` — shared across entities, preferences and facts so no kind is crowded out. -- **`build_nams_settings(..., workspace_id=)`** — optional explicit workspace for the - Strands NAMS helpers, instead of only the ambient `MEMORY_WORKSPACE_ID`. - **Strands SessionManager** (`Neo4jSessionManager`) — automatic conversation persistence/restore for AWS Strands agents via `Agent(session_manager=...)`, backed by any `MemoryClient` (bolt or NAMS). Includes opt-in long-term diff --git a/Makefile b/Makefile index bbfd651d..3a20283c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install install-all install-dev lint format typecheck ty test test-unit test-integration test-integration-mcp test-e2e test-strands-agent-e2e test-all test-docker test-ci test-no-docker test-quick test-file test-match test-aws test-nams-unit test-nams-integration test-nams-staging test-nams-sandbox test-nams-local test-nams coverage coverage-all coverage-ci coverage-mcp test-examples test-examples-quick test-examples-no-neo4j test-docs test-docs-syntax test-docs-build test-docs-links neo4j-start neo4j-stop neo4j-logs clean build publish docs docs-diagrams-list docs-diagrams-status docs-diagrams-missing docs-diagrams-manifest docs-diagrams-add-refs docs-diagrams-generate example-basic example-resolution example-langchain example-pydantic examples chat-agent-install chat-agent-backend chat-agent-frontend chat-agent ts-install ts-build ts-test ts-test-unit ts-test-integration ts-lint ts-docs ts-conformance ts-pack ts-clean ts-test-examples +.PHONY: help install install-all install-dev lint format typecheck ty test test-unit test-integration test-integration-mcp test-e2e test-all test-docker test-ci test-no-docker test-quick test-file test-match test-aws test-nams-unit test-nams-integration test-nams-staging test-nams-sandbox test-nams-local test-nams coverage coverage-all coverage-ci coverage-mcp test-examples test-examples-quick test-examples-no-neo4j test-docs test-docs-syntax test-docs-build test-docs-links neo4j-start neo4j-stop neo4j-logs clean build publish docs docs-diagrams-list docs-diagrams-status docs-diagrams-missing docs-diagrams-manifest docs-diagrams-add-refs docs-diagrams-generate example-basic example-resolution example-langchain example-pydantic examples chat-agent-install chat-agent-backend chat-agent-frontend chat-agent ts-install ts-build ts-test ts-test-unit ts-test-integration ts-lint ts-docs ts-conformance ts-pack ts-clean ts-test-examples # Default target help: @@ -146,12 +146,6 @@ test-e2e: @echo "Running end-to-end MCP flow tests with testcontainers..." uv run pytest tests/integration/test_mcp_e2e.py -v --timeout=300 -# End-to-end: a real Strands Agent + Neo4jMemoryStore against live NAMS and a -# local Ollama. Skips cleanly unless MEMORY_API_KEY is in the *process* env and -# the Ollama endpoint answers. NAMS_E2E_KEEP=1 leaves the run's data in place. -test-strands-agent-e2e: - uv run --env-file .env pytest tests/e2e/test_strands_agent_nams_e2e.py -v -s --timeout=900 - # NAMS unit tests (respx-based, no Docker required) — v0.4 test-nams-unit: uv run pytest tests/unit/nams -v diff --git a/pyproject.toml b/pyproject.toml index 91c2c111..352acfa0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -193,7 +193,6 @@ markers = [ "imports: marks tests as import validation (medium)", "slow: marks tests as slow (external link checks, full builds)", "aws: marks tests as AWS integration tests (require AWS credentials)", - "e2e: marks tests as end-to-end tests driving a real framework agent against live services (skip unless credentials + a local LLM are reachable)", ] filterwarnings = [ "ignore::DeprecationWarning", diff --git a/src/neo4j_agent_memory/integrations/strands/config.py b/src/neo4j_agent_memory/integrations/strands/config.py index aa07ffe2..b2f3fec5 100644 --- a/src/neo4j_agent_memory/integrations/strands/config.py +++ b/src/neo4j_agent_memory/integrations/strands/config.py @@ -182,7 +182,6 @@ def build_nams_settings( transport_mode: TransportMode = "auto", *, validate_on_connect: bool = False, - workspace_id: str | None = None, ) -> MemorySettings: """Build NAMS-backed MemorySettings (validate_on_connect off by default — Strands drives short synchronous bursts; skipping the probe saves a round-trip). @@ -192,10 +191,6 @@ def build_nams_settings( api_key: NAMS API key. transport_mode: MCP / REST transport selection. validate_on_connect: Whether to probe the service on connect. - workspace_id: Explicit NAMS workspace, transmitted as ``X-Workspace-Id``. - Left ``None``, ``MemorySettings`` falls back to ``MEMORY_WORKSPACE_ID`` - from the environment — pass it when the caller must not inherit - whatever workspace the ambient environment names. """ from pydantic import SecretStr @@ -208,7 +203,6 @@ def build_nams_settings( api_key=SecretStr(api_key), validate_on_connect=validate_on_connect, transport_mode=transport_mode, - workspace_id=workspace_id, ), ) diff --git a/src/neo4j_agent_memory/nams/short_term.py b/src/neo4j_agent_memory/nams/short_term.py index c6f1af27..7b97205e 100644 --- a/src/neo4j_agent_memory/nams/short_term.py +++ b/src/neo4j_agent_memory/nams/short_term.py @@ -185,15 +185,12 @@ def _normalize_conversation( ) -> dict[str, Any]: """Map NAMS Conversation response → bolt Pydantic shape. - NAMS returns ``{id, userId, workspaceId, metadata, createdAt, - updatedAt}`` from GET, and ``{id, userId, workspaceId, metadata}`` - from create — verified live: ``metadata`` round-trips through both - create and ``list_conversations``. The bolt Pydantic ``Conversation`` - model requires ``id``, ``session_id``, and ``created_at``. We - synthesize ``session_id`` from the caller-supplied value (which is - typically the NAMS conversation UUID). ``metadata`` still defaults to - ``{}`` below when absent, since older or differently-configured NAMS - deployments may omit it. + NAMS returns ``{id, userId, workspaceId, metadata, createdAt, updatedAt}`` + from GET and ``{id, userId, workspaceId, metadata}`` from create. The bolt + ``Conversation`` model requires ``id``, ``session_id`` and ``created_at``, + so ``session_id`` is synthesized from the caller-supplied value (typically + the NAMS conversation UUID) and ``metadata`` defaults to ``{}`` when a + deployment omits it. """ data = snakeize_keys(payload) if isinstance(payload, dict) else {} if "session_id" not in data: diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py deleted file mode 100644 index efecf603..00000000 --- a/tests/e2e/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""End-to-end tests that drive a real framework agent against live services.""" diff --git a/tests/e2e/test_strands_agent_nams_e2e.py b/tests/e2e/test_strands_agent_nams_e2e.py deleted file mode 100644 index 7d6581fd..00000000 --- a/tests/e2e/test_strands_agent_nams_e2e.py +++ /dev/null @@ -1,469 +0,0 @@ -"""End-to-end: a real Strands ``Agent`` + ``Neo4jMemoryStore`` against live NAMS. - -Why this file exists -==================== -Every other test of :class:`~neo4j_agent_memory.integrations.strands.memory_store.Neo4jMemoryStore` -drives the store *directly*, on one event loop. That is exactly the blind spot -that let a Critical through review: Strands' **synchronous** entry point runs -``Agent.__init__`` on one throwaway loop and every ``Agent.__call__`` on -another (``strands._async.run_async`` is ``asyncio.run`` in a worker thread), -so a store built from ``settings=`` connected its neo4j/httpx client on the -construction loop and then raised ``RuntimeError: ... attached to a different -loop`` from the first call. ``initialize()`` now rebinds an owned client when -the loop changes; nothing exercised it end-to-end until here. - -So this test constructs a real ``Agent``, calls it **twice** synchronously -(construction-loop -> call-loop, then call-loop -> call-loop) and then verifies -the memory actually landed in, and came back out of, hosted NAMS. - -A test, not an example -====================== -It needs per-run unique names, teardown that runs on assertion failure, and a -skip gate that keeps it out of everyone else's CI. pytest gives all three for -free; a script in ``examples/`` would hand-roll them and never be run. - -Running it -========== -Credentials must be in the *process* environment (``MemorySettings``' dotenv -source filters out keys that are not top-level model fields), so:: - - uv run --env-file .env pytest tests/e2e/test_strands_agent_nams_e2e.py -v -s - -Requires, or it skips cleanly: - -* ``NAMS_E2E_WORKSPACE_ID`` — a **throwaway** workspace, necessarily different - from ``MEMORY_WORKSPACE_ID``. See "Why a throwaway workspace" below. -* ``MEMORY_API_KEY`` (plus optional ``MEMORY_ENDPOINT``). -* A local Ollama answering on ``OLLAMA_BASE_URL`` (default - ``http://localhost:11434/v1``) serving a **tool-calling** model. - ``MemoryManager`` registers ``search_memory`` plus the store's graph tools, so - a model that rejects ``tools`` cannot drive this test at all. - -Why a throwaway workspace -========================= -Teardown is only as complete as NAMS lets it be. ``clear_session`` deletes the -conversation; the entities NAMS extracted *from* it survive in the workspace. -``NamsLongTermMemory`` declares a ``DELETE /entities/{id}`` endpoint spec -(``_SPEC_DELETE_ENTITY``) but exposes no ``delete_entity()`` method, so there is -no public route to remove them — nor the pending fuzzy-merge candidates the -resolver files against them. A run is therefore a **one-way write**, which is -why this test refuses to target a workspace anybody works in. The gate lives in -``tests/nams_live.py``; it is checked before the credential and Ollama gates so -that the reason a run did not happen is never ambiguous. - -Environment knobs -================= -``OLLAMA_BASE_URL`` / ``OLLAMA_MODEL_ID`` - Point the test at a different local LLM. -``NAMS_E2E_KEEP=1`` - **Skip teardown** and leave this run's conversation in the workspace, for - inspecting it in the NAMS web UI. Unset (the default) deletes every - conversation the run created and asserts they are gone — as much cleanup as - the SDK can do. - -The one thing that makes the gate look broken -============================================= -*The ``opik`` pytest plugin loads the repo-root ``.env`` into ``os.environ``,* -whatever the shell says. So under ``uv run pytest`` the credentials *and* -``MEMORY_WORKSPACE_ID`` are present even without ``--env-file``, and the skip -gates look inert locally. To see them actually skip, disable that plugin:: - - uv run pytest tests/e2e/test_strands_agent_nams_e2e.py -p no:opik - -Two agents lost an hour to this before it was written here. In CI, where there -is no ``.env``, the gates hold either way. -""" - -from __future__ import annotations - -import asyncio -import copy -import json -import logging -import os -import urllib.error -import urllib.request -import uuid -from collections.abc import AsyncGenerator, Iterator -from typing import Any - -import pytest - -from tests.nams_live import skip_without_throwaway_workspace, unique_codename - -#: The only workspace this run is allowed to write to. Resolved before every -#: other gate so a run that would have landed in somebody's working workspace -#: reports *that*, rather than whichever credential it also happens to lack. -THROWAWAY_WORKSPACE_ID = skip_without_throwaway_workspace() - -pytest.importorskip("strands", reason="strands-agents not installed") - -from strands import Agent -from strands.memory import ( - ExtractionConfig, - InvocationTrigger, - MemoryEntry, - MemoryInjectionConfig, - MemoryManager, -) -from strands.models.openai import OpenAIModel -from strands.types.content import Message -from strands.types.streaming import StreamEvent -from strands.types.tools import ToolSpec - -from neo4j_agent_memory import MemoryClient -from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore, Neo4jMemoryStoreConfig -from neo4j_agent_memory.integrations.strands.config import ( - build_nams_settings, - resolve_nams_connection, -) -from neo4j_agent_memory.integrations.strands.memory_store import _STORE_KEY - -logger = logging.getLogger(__name__) - -# Not `integration`: the root conftest auto-skips that marker whenever Neo4j is -# unreachable, and this test wants NAMS + Ollama, not Neo4j. -pytestmark = pytest.mark.e2e - -#: OpenAI-compatible Ollama endpoint. Overridable so this is not hard-wired to a laptop. -OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434/v1") - -#: Ollama ignores the key but the OpenAI client requires a non-empty one. -OLLAMA_API_KEY = "ollama" - -#: Must emit real ``tool_calls``. Verified: ``qwen3.5:9b`` does; ``gemma3:12b`` -#: answers "gemma3:12b does not support tools" and cannot drive this test. -OLLAMA_MODEL_ID = os.environ.get("OLLAMA_MODEL_ID", "qwen3.5:9b") - -#: Leave this run's NAMS data in place instead of tearing it down. -KEEP_NAMS_DATA = os.environ.get("NAMS_E2E_KEEP", "").strip().lower() in {"1", "true", "yes"} - -#: Seconds to wait for NAMS' asynchronous, server-side extraction pipeline. -EXTRACTION_TIMEOUT = 180.0 - -#: Seconds any single live call may block, so a hung service cannot hang a dev box. -CALL_TIMEOUT = 240.0 - -#: One run's namespace. Store name is kept slug-safe so the store's tool prefix -#: equals it verbatim (see ``_store_tools._tool_prefix``) and the expected tool -#: name below needs no private helper to compute. -RUN_ID = uuid.uuid4().hex[:10] -STORE_NAME = f"e2e_{RUN_ID}" -USER_ID = f"e2e-user-{RUN_ID}" - -#: What the store derives its sink conversation's ``session_id`` / metadata tag -#: from (``Neo4jMemoryStore._sink_name``). Recomputed here so a failing run -#: prints something the owner can search the workspace for. -SINK_NAME = f"strands-memory-store/{USER_ID}/{STORE_NAME}" - -#: The fixture entity's name: pronounceable nonsense no other workspace data can -#: contain, so recall assertions are about *our* memory rather than a -#: nearest-neighbour hit on someone else's. -#: -#: Drawn from ``tests.nams_live``'s mutually-distant codename pool and -#: deliberately **not** ``RUN_ID``-suffixed. A shared stem plus a hex suffix -#: (the first cut: ``Zorbium89980F486C``, ``Zorbium868C72705A``, …) makes every -#: run ~85% similar to every previous one, so NAMS' resolver filed a pending -#: fuzzy-merge candidate per pair — a review backlog that, per the note above, -#: nothing in the SDK can clear. Codenames are <= 54.5% similar to each other, -#: well under the threshold. ``RUN_ID`` still namespaces the store name and user -#: id below (sink naming, not entity data), so runs stay traceable. -TOKEN = unique_codename() - -#: Seeded via ``store.add()``. Recall is asserted against this rather than -#: against the model's own turn text: NAMS extracts server-side and -#: nearest-neighbour entity search would not let us assert deterministically on -#: whatever a 9B local model happened to say. -SEEDED_MEMORY = ( - f"{TOKEN} is the codename of the quarterly planning ritual " - f"at Contoso Robotics, run every March in Reykjavik." -) - - -# --------------------------------------------------------------------------- -# Skip gate -# --------------------------------------------------------------------------- - - -def _ollama_models() -> list[str] | None: - """Model names Ollama serves, or ``None`` when it does not answer. - - Short timeout on purpose: a contributor without a local LLM must get a - skip in a second, not a hang. - """ - tags_url = OLLAMA_BASE_URL.removesuffix("/v1").rstrip("/") + "/api/tags" - try: - with urllib.request.urlopen(tags_url, timeout=5) as response: # noqa: S310 - fixed localhost URL - payload = json.loads(response.read()) - except (urllib.error.URLError, OSError, ValueError, TimeoutError): - return None - models = payload.get("models") if isinstance(payload, dict) else None - return [str(entry.get("name")) for entry in (models or []) if isinstance(entry, dict)] - - -def _skip_reason() -> str | None: - """Why this test cannot run here, or ``None`` when it can. - - The throwaway-workspace gate is *not* checked here: it runs at module scope - (see ``THROWAWAY_WORKSPACE_ID``) so it wins over these reachability checks. - """ - if not os.environ.get("MEMORY_API_KEY"): - return ( - "MEMORY_API_KEY is not in the process environment. Run with " - "`uv run --env-file .env pytest ...` -- MemorySettings' dotenv source " - "drops non-field keys, so a bare .env is not enough." - ) - served = _ollama_models() - if served is None: - return f"No Ollama at {OLLAMA_BASE_URL} (checked /api/tags with a 5s timeout)." - if OLLAMA_MODEL_ID not in served: - return f"Ollama does not serve {OLLAMA_MODEL_ID!r}. Pull it or set OLLAMA_MODEL_ID." - return None - - -@pytest.fixture(scope="module", autouse=True) -def _gate() -> None: - reason = _skip_reason() - if reason: - pytest.skip(reason) - - -# --------------------------------------------------------------------------- -# NAMS helpers -- a client of our own, independent of the store's -# --------------------------------------------------------------------------- - - -def _nams_settings() -> Any: - """NAMS settings pinned to the throwaway workspace. - - ``workspace_id`` is passed explicitly (the client transmits it as - ``X-Workspace-Id``) rather than left to ``MemorySettings``' fallback onto - ``MEMORY_WORKSPACE_ID`` — which is exactly the working workspace this test - must not touch. - """ - endpoint, api_key = resolve_nams_connection() - return build_nams_settings(endpoint, api_key, workspace_id=THROWAWAY_WORKSPACE_ID) - - -async def _our_conversations(client: MemoryClient) -> list[Any]: - """Conversations this run's store created, found by its sink metadata tag. - - Matches on ``RUN_ID`` rather than on ``SINK_NAME`` verbatim, so it still - finds the sink if the store's naming scheme changes -- both the store name - and the user id embed ``RUN_ID``. - """ - found = [] - for conversation in await client.short_term.list_conversations(limit=1000): - tag = (conversation.metadata or {}).get(_STORE_KEY) - if tag and RUN_ID in str(tag): - found.append(conversation) - return found - - -@pytest.fixture(scope="module") -def nams_teardown() -> Iterator[None]: - """Delete every conversation this run created, then prove it is gone. - - Module-scoped and ``finally``-guarded so it runs even when an assertion - fails mid-test -- the point is never to leave data in a shared workspace. - ``NAMS_E2E_KEEP=1`` opts out, for inspecting the run in the NAMS web UI. - """ - # TOKEN is printed because it is no longer derivable from RUN_ID: it is the - # only handle on the entities this run leaves behind in the workspace. - print( - f"\n[run] id={RUN_ID} store={STORE_NAME} user_id={USER_ID} " - f"sink_name={SINK_NAME} codename={TOKEN}" - ) - try: - yield - finally: - - async def _cleanup() -> None: - async with MemoryClient(_nams_settings()) as client: - doomed = await _our_conversations(client) - ids = [str(conversation.id) for conversation in doomed] - print(f"\n[cleanup] conversations created by run {RUN_ID}: {ids}") - if KEEP_NAMS_DATA: - print("[cleanup] NAMS_E2E_KEEP=1 -- leaving the above in place.") - return - for conversation_id in ids: - print(f"[cleanup] clear_session({conversation_id})") - await client.short_term.clear_session(conversation_id) - remaining = await _our_conversations(client) - print(f"[cleanup] remaining after delete: {[str(c.id) for c in remaining]}") - assert remaining == [], f"cleanup left {len(remaining)} conversation(s) behind" - - asyncio.run(asyncio.wait_for(_cleanup(), timeout=CALL_TIMEOUT)) - - -# --------------------------------------------------------------------------- -# A model that records what it was actually asked -- for the injection assertion -# --------------------------------------------------------------------------- - - -class RecordingModel(OpenAIModel): - """``OpenAIModel`` that keeps a copy of every message list it is handed. - - Subclassing and overriding the public ``Model.stream`` is the supported - extension point, so the injection assertion reads what the model really - received rather than reaching into ``MemoryManager``'s internals. - """ - - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - self.received: list[list[Message]] = [] - - async def stream( - self, - messages: list[Message], - tool_specs: list[ToolSpec] | None = None, - system_prompt: str | None = None, - **kwargs: Any, - ) -> AsyncGenerator[StreamEvent, None]: - self.received.append(copy.deepcopy(messages)) - async for event in super().stream(messages, tool_specs, system_prompt, **kwargs): - yield event - - def all_text(self) -> str: - """Every text block from every recorded call, concatenated.""" - chunks: list[str] = [] - for messages in self.received: - for message in messages: - for block in message.get("content") or []: - text = block.get("text") if isinstance(block, dict) else None - if text: - chunks.append(str(text)) - return "\n".join(chunks) - - -# --------------------------------------------------------------------------- -# The test -# --------------------------------------------------------------------------- - - -async def _seed_and_await_extraction(store: Neo4jMemoryStore) -> str: - """Write the seed memory, then wait until NAMS has actually extracted it. - - Ordering matters and is the whole reason this is a separate step: - ``MemoryManager``'s injection *fails open* -- when the store's search comes - back empty it silently injects nothing. NAMS extracts asynchronously, so a - seed written immediately before the first turn is not yet searchable and the - injection assertion would fail for a reason that is not a defect. Await the - pipeline first; only then is "did the ```` block reach the model?" a - meaningful question. - - Returns the sink conversation id. - """ - written = await store.add(SEEDED_MEMORY) - print(f"[seed] store.add -> {written}") - - async with MemoryClient(_nams_settings()) as client: - conversations = await _our_conversations(client) - assert len(conversations) == 1, ( - f"expected exactly one sink conversation for run {RUN_ID}, got {len(conversations)}" - ) - sink_id = str(conversations[0].id) - print(f"[seed] sink conversation id = {sink_id}") - - settled = await client.long_term.wait_for_extraction( - session_id=sink_id, timeout=EXTRACTION_TIMEOUT, interval=2.0 - ) - status = await client.short_term.get_extraction_status(sink_id) - print(f"[seed] extraction settled={settled} status={status.summary}") - assert settled, f"NAMS extraction did not settle within {EXTRACTION_TIMEOUT}s" - - def _has_token(entities: list[Any]) -> bool: - return any(TOKEN.lower() in (entity.name or "").lower() for entity in entities) - - searchable = await client.long_term.wait_for_extraction( - query=TOKEN, predicate=_has_token, timeout=EXTRACTION_TIMEOUT, interval=3.0 - ) - print(f"[seed] {TOKEN} searchable={searchable}") - assert searchable, f"{TOKEN} never became searchable in NAMS" - - entities = await client.long_term.search_entities(TOKEN, limit=10) - print(f"[seed] entities matching {TOKEN}: {[e.name for e in entities]}") - - return sink_id - - -async def _verify_recall(store: Neo4jMemoryStore, sink_id: str) -> None: - """Assertions 2 and 3: the turns landed in the sink, and recall works.""" - async with MemoryClient(_nams_settings()) as client: - conversation = await client.short_term.get_conversation(sink_id) - texts = [message.content for message in conversation.messages] - print(f"[verify] {len(texts)} message(s) in the sink") - for text in texts: - print(f"[verify] {text[:200]}") - assert any(SEEDED_MEMORY in text for text in texts), "store.add() text missing from sink" - assert any(TOKEN in text and SEEDED_MEMORY not in text for text in texts), ( - "no agent turn text in the sink -- extraction/add_messages never ran" - ) - - entries: list[MemoryEntry] = await store.search(TOKEN) - print(f"[verify] store.search({TOKEN}) -> {len(entries)} entr(y/ies)") - for entry in entries: - print(f"[verify] {entry.content} :: {entry.metadata}") - assert entries, "store.search returned nothing" - assert any(TOKEN.lower() in entry.content.lower() for entry in entries), ( - "store.search returned entries, but none derived from the seeded memory" - ) - - -def test_sync_agent_drives_nams_backed_memory_store(nams_teardown: None) -> None: - """The whole product path, on the synchronous entry point that broke. - - Deliberately a **sync** test: ``asyncio_mode = "auto"`` would otherwise run - the body inside a loop, and it is precisely Strands' sync entry point -- - a fresh loop per call -- that this test exists to cover. Each async - verification step gets its own ``asyncio.run``, which incidentally - exercises the owned-client rebind once more. - """ - store = Neo4jMemoryStore( - Neo4jMemoryStoreConfig( - name=STORE_NAME, - settings=_nams_settings(), - user_id=USER_ID, - # Server-side extraction: the store implements add_messages, so the - # manager hands it the filtered turn directly -- no model call. - extraction=ExtractionConfig(trigger=InvocationTrigger()), - max_search_results=5, - ) - ) - model = RecordingModel( - client_args={"base_url": OLLAMA_BASE_URL, "api_key": OLLAMA_API_KEY}, - model_id=OLLAMA_MODEL_ID, - ) - manager = MemoryManager(stores=[store], injection=MemoryInjectionConfig(max_entries=5)) - - sink_id = asyncio.run( - asyncio.wait_for(_seed_and_await_extraction(store), timeout=EXTRACTION_TIMEOUT * 2 + 60) - ) - - # --- 1. The synchronous path ------------------------------------------- - # Agent.__init__ initializes the store on one loop... - agent = Agent(model=model, memory_manager=manager) - print("[agent] constructed") - - # ...and each __call__ drives it from another. Two calls: construction-loop - # -> call-loop, then call-loop -> call-loop. - first = agent(f"In one sentence, what do you know about {TOKEN}?") - print(f"[turn 1] {str(first)[:400]}") - second = agent("And where does it happen? One short sentence.") - print(f"[turn 2] {str(second)[:400]}") - - # --- 4. Namespaced tools ------------------------------------------------ - print(f"[tools] {sorted(agent.tool_names)}") - assert f"{STORE_NAME}_get_entity_graph" in agent.tool_names - # NAMS has no preferences endpoint, so the store must not ship this tool. - assert f"{STORE_NAME}_get_user_preferences" not in agent.tool_names - assert "search_memory" in agent.tool_names - - # --- 5. Injection reached the model ------------------------------------ - sent = model.all_text() - assert "" in sent, "MemoryManager's default block never reached the model" - assert TOKEN in sent, f"the seeded memory ({TOKEN}) was not among the injected entries" - - # --- 2 & 3. It landed in NAMS, and comes back out ---------------------- - asyncio.run(asyncio.wait_for(_verify_recall(store, sink_id), timeout=CALL_TIMEOUT)) - asyncio.run(store.aclose()) diff --git a/tests/integration/nams/test_strands_memory_store.py b/tests/integration/nams/test_strands_memory_store.py index 057f02f2..9b0ea0c6 100644 --- a/tests/integration/nams/test_strands_memory_store.py +++ b/tests/integration/nams/test_strands_memory_store.py @@ -1,28 +1,12 @@ """Live-NAMS integration test — ``Neo4jMemoryStore`` sink resolution. -Verifies the metadata round-trip a manual live probe confirmed (see -``.superpowers/sdd/2026-08-19-strands-memory-store/nams-live-verification-report.md``): -NAMS returns conversation ``metadata`` on both ``create`` and -``list_conversations``, which is exactly what -``Neo4jMemoryStore._resolve_nams_sink`` depends on to find its existing -sink across restarts instead of minting a fresh conversation every time. - -The spec's Testing table (``docs/superpowers/specs/2026-08-19-strands-memory-store-design.md``) -promised this suite as "key-gated NAMS ... not yet implemented" — this -closes that gap. - -Gated on a throwaway workspace -============================== -This test writes to live NAMS, and the SDK cannot fully undo it: -``clear_session`` deletes the sink conversation but not the entities NAMS -extracted from it, and there is no ``delete_entity()``. So it refuses to run -unless ``NAMS_E2E_WORKSPACE_ID`` names a workspace that is *not* -``MEMORY_WORKSPACE_ID`` — see ``tests/nams_live.py`` for the full reasoning. - -To watch that skip actually happen, pass ``-p no:opik``: the ``opik`` pytest -plugin loads the repo-root ``.env`` into ``os.environ`` whatever the shell says, -so under a plain ``uv run pytest`` the workspace variables are present and the -gate looks inert. Two agents lost an hour to that before it was written down. +``Neo4jMemoryStore._resolve_nams_sink`` finds its existing sink conversation by +a metadata tag, so it depends on NAMS returning conversation ``metadata`` from +both ``create_conversation`` and ``list_conversations``. This asserts that +round-trip against the live service. + +Creates conversations only — no messages, so NAMS extracts nothing and +``clear_session`` teardown is complete. """ from __future__ import annotations @@ -31,53 +15,19 @@ import pytest import pytest_asyncio -from pydantic import SecretStr - -from tests.nams_live import skip_without_throwaway_workspace - -# First gate, before the credential and dependency gates: a run that would land -# in somebody's working workspace must report *that* as the reason it did not -# happen, not a missing key it also happens to lack. -THROWAWAY_WORKSPACE_ID = skip_without_throwaway_workspace() pytest.importorskip("strands", reason="strands-agents not installed") -from neo4j_agent_memory import MemoryClient, NamsConfig +from neo4j_agent_memory import MemoryClient from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore, Neo4jMemoryStoreConfig from neo4j_agent_memory.integrations.strands.memory_store import _STORE_KEY pytestmark = pytest.mark.integration -@pytest.fixture -def nams_config(nams_credentials: tuple[str, str, str | None]) -> NamsConfig: - """Overrides ``conftest.py``'s fixture to pin the **throwaway** workspace. - - The conftest default resolves the workspace from ``MEMORY_WORKSPACE_ID`` / - ``NAMS_SANDBOX_WORKSPACE_ID``. This module must never write there, so the - id from ``NAMS_E2E_WORKSPACE_ID`` is wired into ``NamsConfig`` explicitly - (the client transmits it as ``X-Workspace-Id``) rather than left to - whatever the ambient environment resolves to. - """ - endpoint, api_key, _ = nams_credentials - return NamsConfig( - endpoint=endpoint, - api_key=SecretStr(api_key), - workspace_id=THROWAWAY_WORKSPACE_ID, - validate_on_connect=False, - max_retries=2, - retry_backoff_seconds=0.5, - timeout=20.0, - ) - - @pytest_asyncio.fixture async def _sink_cleanup(nams_client: MemoryClient) -> AsyncIterator[list[str]]: - """Tracks conversation ids this test resolves/creates; best-effort teardown. - - Runs even when an assertion above fails, so a broken test never leaves - a conversation behind in the shared sandbox workspace. - """ + """Conversation ids to delete afterwards, even if an assertion fails.""" created: list[str] = [] try: yield created @@ -97,9 +47,8 @@ async def test_resolve_sink_reuses_metadata_tagged_conversation( ) -> None: """A second store with the same ``name``/``user_id`` resolves the same sink. - ``test_run_id`` is a fresh UUID-suffixed prefix per test invocation (see - ``conftest.py``), so the store name here can't collide with, or be - mistaken for, another concurrent or prior run's sink. + ``test_run_id`` is a per-invocation UUID prefix (see ``conftest.py``), so + this store name cannot collide with a concurrent or prior run's sink. """ store_name = f"{test_run_id}-store" user_id = f"{test_run_id}-user" @@ -111,12 +60,12 @@ async def test_resolve_sink_reuses_metadata_tagged_conversation( Neo4jMemoryStoreConfig(name=store_name, client=nams_client, user_id=user_id) ) - # First store: no existing sink yet -- creates one, tagged with metadata. + # No sink exists yet: this creates one, tagged with metadata. sink_a = await store_a._resolve_sink() _sink_cleanup.append(sink_a) - # Second, independent store instance with the same name/user_id: must - # find the same sink via the metadata tag rather than creating another. + # An independent store instance with the same name/user_id must find that + # sink through the tag rather than create a second one. sink_b = await store_b._resolve_sink() assert sink_a == sink_b diff --git a/tests/nams_live.py b/tests/nams_live.py deleted file mode 100644 index 05033033..00000000 --- a/tests/nams_live.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Helpers for tests that write to the **live** hosted NAMS service. - -Why a dedicated throwaway workspace is mandatory -================================================ -The SDK cannot undo what these tests create. ``clear_session`` deletes a -conversation but *not* the entities NAMS extracted from it, and -``NamsLongTermMemory`` declares a ``DELETE /entities/{id}`` endpoint spec -(``_SPEC_DELETE_ENTITY``) while exposing no ``delete_entity()`` method — so -there is no public route to remove an entity. Entities, and the resolver's -pending fuzzy-merge candidates, therefore accumulate **permanently** in -whatever workspace the credentials happen to point at. A live run is a one-way -write, and every repeat run grows a review backlog somebody has to clear by -hand in the NAMS UI. - -So live-NAMS tests gate on ``NAMS_E2E_WORKSPACE_ID``: a workspace that exists -only to be polluted. It is deliberately a *different* variable from -``MEMORY_WORKSPACE_ID`` — the one a developer's ``.env`` already points at -their working workspace — so a working workspace can never become the target -by ambient default, and the gate refuses to run when the two are equal. - -Verifying the gate locally -========================== -The ``opik`` pytest plugin loads the repo-root ``.env`` into ``os.environ`` -whatever the shell says, so under a plain ``uv run pytest`` these variables are -present and the gate looks inert. Disable that plugin to see it fire:: - - uv run pytest -p no:opik - -Both prior attempts at this gate lost time to that. In CI, where there is no -``.env``, the gate holds either way. - -Entity names that cannot pile up in the resolver's review queue -=============================================================== -``unique_codename`` exists because the first cut of the e2e fixture built its -entity name from one stem plus a hex run suffix (``Zorbium89980F486C``, -``Zorbium868C72705A``, ``ZorbiumEEEF8D7F7E``). Those are ~85% similar to each -other, so every run handed NAMS' resolver a fresh pending fuzzy-merge candidate -against every previous run's entity — the exact backlog no SDK call can clear. -See ``_CODENAMES`` for the replacement and its measured similarity bound. -""" - -from __future__ import annotations - -import os -import random - -import pytest - -#: The throwaway workspace these tests are allowed to write to. -E2E_WORKSPACE_ENV = "NAMS_E2E_WORKSPACE_ID" - -#: Variables that name a workspace somebody actually works in; the throwaway -#: one must not equal any of them. ``NAMS_SANDBOX_WORKSPACE_ID`` is the -#: integration suite's alias for the same thing (``integration/nams/conftest.py``). -WORKING_WORKSPACE_ENVS = ("MEMORY_WORKSPACE_ID", "NAMS_SANDBOX_WORKSPACE_ID") - -#: Stated in both skip reasons: the gate is not a style preference. -_WHY = ( - "these tests leave behind entities -- and the pending fuzzy-merge " - "candidates NAMS' resolver queues for them -- that cannot be deleted " - "through the SDK (clear_session removes the conversation only; " - "NamsLongTermMemory exposes no delete_entity())" -) - -#: Repeated in both reasons because it is the single thing that makes this gate -#: look broken when you try to verify it (see the module docstring). -_HOW_TO_VERIFY = "Verify this skip with `-p no:opik` -- the opik plugin loads .env into os.environ." - - -def throwaway_workspace_id() -> str | None: - """The configured throwaway workspace id, or ``None`` when unset/blank.""" - return os.environ.get(E2E_WORKSPACE_ENV, "").strip() or None - - -def throwaway_workspace_skip_reason() -> str | None: - """Why a live-NAMS test must not run here, or ``None`` when it may. - - Two refusals, never a silent proceed: - - * ``NAMS_E2E_WORKSPACE_ID`` unset — no throwaway workspace was nominated. - * it equals a working-workspace variable — the nominated workspace is one - somebody uses. - """ - workspace = throwaway_workspace_id() - if workspace is None: - return ( - f"{E2E_WORKSPACE_ENV} is not set. Live-NAMS tests run only against a " - f"dedicated throwaway workspace, because {_WHY}. It is a separate " - f"variable from {WORKING_WORKSPACE_ENVS[0]} so a working workspace " - f"cannot become the target by ambient default. {_HOW_TO_VERIFY}" - ) - for name in WORKING_WORKSPACE_ENVS: - if (os.environ.get(name, "").strip() or None) == workspace: - return ( - f"{E2E_WORKSPACE_ENV} equals {name}: the throwaway workspace must " - f"not be the working one, because {_WHY}. Point " - f"{E2E_WORKSPACE_ENV} at a separate, disposable workspace. " - f"{_HOW_TO_VERIFY}" - ) - return None - - -def skip_without_throwaway_workspace() -> str: - """Skip the calling module unless a throwaway workspace is configured. - - Call at **module scope, before every other gate**, so this safety check - wins over a credential or service-reachability skip: otherwise a missing - ``MEMORY_API_KEY`` reports first and hides whether the workspace gate is - even wired up. - - Returns: - The throwaway workspace id, to pass explicitly into ``NamsConfig`` / - ``build_nams_settings`` rather than letting ``MemorySettings`` pick one - up from the ambient environment. - """ - reason = throwaway_workspace_skip_reason() - if reason is not None: - pytest.skip(reason, allow_module_level=True) - workspace = throwaway_workspace_id() - assert workspace is not None, "throwaway_workspace_skip_reason() passed with no workspace" - return workspace - - -# --------------------------------------------------------------------------- -# Per-run entity codenames -# --------------------------------------------------------------------------- - -#: Pronounceable nonsense words, one drawn per live run to name that run's -#: fixture entity. Two properties matter, and neither survives the -#: stem-plus-suffix scheme this replaces: -#: -#: * **Mutually distant.** Built by greedy rejection sampling that admitted a -#: candidate only when it scored <= 55 against every word already in the pool -#: under *all four* of ``rapidfuzz``'s ``ratio``, ``WRatio``, -#: ``partial_ratio`` and ``token_sort_ratio``. Measured worst case over all -#: 8128 pairs: **54.5** (``Zuczuto`` / ``Fuzocal``), far below the 85% that -#: made NAMS' resolver queue a merge candidate. No word is a prefix, suffix or -#: substring of another, so substring-flavoured scorers cannot inflate a pair -#: either -- which is why a codename is one word and not two concatenated -#: (concatenating two pool words shares a whole half verbatim and measured -#: 81.5 under ``partial_ratio``). -#: * **Not real words.** Nothing here can collide with, or be mistaken for, -#: genuine entities already in a workspace, so a recall assertion on a -#: codename is an assertion about *this run's* memory. -#: -#: Two runs either draw different codenames (<= 54.5% similar, no candidate) or -#: the same one (1 in 128), which is an *exact* match: NAMS resolves it to the -#: one existing entity and queues nothing. Runs stay individually traceable -#: regardless, because the store name, user id and sink metadata still carry the -#: hex run id. -# fmt: off -_CODENAMES = ( - "Daplokid", "Dudekad", "Zikaken", "Ripolisi", "Kunmima", "Gepdimiz", "Zasovor", "Memfapol", - "Feksenoc", "Puftisom", "Zokfeto", "Noravot", "Vabzebu", "Defkitek", "Fomubig", "Sicveru", - "Sovegof", "Fupmodo", "Vavezin", "Tebobizo", "Kocgure", "Dafeduz", "Rumzabem", "Filapor", - "Bibrigek", "Makmimiv", "Bagpavoc", "Gugrasa", "Mavzubef", "Nenazem", "Sarezolu", "Pakuceko", - "Sikumesu", "Tavtalac", "Tafbekog", "Gabiduko", "Gagudili", "Sotbokun", "Pacabag", "Gecomef", - "Mokobus", "Govirul", "Bevuzuc", "Tuczibaz", "Genutiga", "Lukzesek", "Numucipi", "Lusloris", - "Gobofali", "Rozgivuk", "Gupeface", "Casapugu", "Celucup", "Tunlupom", "Dimgufaf", "Pekivif", - "Bacukaco", "Nuvlesab", "Dulnule", "Rituraz", "Dismerac", "Ziscepe", "Bilodozu", "Zuczuto", - "Karitavu", "Fozuteni", "Vurkici", "Soscoba", "Cifetem", "Volzafiz", "Gamakut", "Lafviro", - "Pundofon", "Mitzidef", "Modbafol", "Tibinir", "Bedetes", "Zitibimo", "Nizufusu", "Batonaru", - "Carfomev", "Kinapud", "Gozfugob", "Taksupaz", "Ledilas", "Nisrefos", "Piraseg", "Dibolog", - "Nedzapit", "Muvalol", "Lugicol", "Murlagiv", "Zekgosuv", "Renonog", "Domfele", "Lureromo", - "Refveta", "Puvopegu", "Cageleba", "Fuvumic", "Nipkecus", "Nakifak", "Todvesic", "Temosos", - "Pezdadar", "Gocotud", "Cigidup", "Vunpadi", "Rovpare", "Mefgukug", "Kucrukuz", "Kegzode", - "Cisivabo", "Bungukot", "Cogafav", "Rupetib", "Mupsilum", "Mogpogem", "Lektazug", "Fiblusik", - "Zuvekilu", "Bicmanok", "Fuzocal", "Tiklelo", "Lufufec", "Subivane", "Valnalun", "Fasbaciv", -) -# fmt: on - - -def unique_codename() -> str: - """One run's fixture entity name: a codename drawn from ``_CODENAMES``. - - Deliberately *not* a shared stem plus a per-run suffix. Suffixed names are - ~85% similar to each other, which is precisely what makes NAMS' entity - resolver file a pending fuzzy-merge candidate for every pair -- an - ever-growing review backlog in a workspace the SDK cannot clean up. - """ - return random.SystemRandom().choice(_CODENAMES) diff --git a/tests/unit/integrations/strands_fakes.py b/tests/unit/integrations/strands_fakes.py index 06db666b..980ab6a0 100644 --- a/tests/unit/integrations/strands_fakes.py +++ b/tests/unit/integrations/strands_fakes.py @@ -385,11 +385,10 @@ async def get_related_entities(self, entity: Any, **kwargs: Any) -> list[tuple[A direction: ``Neo4jClient.execute_read`` returns ``result.data()``, which renders a relationship as ``(start_props, type, end_props)`` and drops its properties, so ``memory/long_term.py``'s parse falls through - to ``type="RELATED_TO"`` for every hit, with ``source_id`` hardcoded - to the centre. Verified live against Neo4j 5 (see - ``tests/integration/test_strands_memory_store_integration.py``). This - fake reproduces that rather than inventing a richer relationship the - production stack never returns. + to ``type="RELATED_TO"`` for every hit, with ``source_id`` hardcoded to + the centre. ``tests/integration/test_strands_memory_store_integration.py`` + asserts this against a live Neo4j; the fake reproduces it rather than + inventing a richer relationship the production stack never returns. """ self.related_kwargs.append(kwargs) from neo4j_agent_memory.memory.long_term import Relationship From af4d45620dcfa84346b8ffc9e10e8c9d5031e0d1 Mon Sep 17 00:00:00 2001 From: Andreas Berger Date: Thu, 20 Aug 2026 19:33:37 +0200 Subject: [PATCH 39/39] fix(py/strands): address PR review findings - search() and session-manager injection scoped preference recall to the store's user_id. search_preferences applies no :User filter, so a scoped construct could surface another tenant's preferences; get_preferences_for is the only user-scoped primitive, so a scoped lookup lists the user's active preferences instead of searching all of them. - initialize()'s loop guard keys on the recorded loop, not _initialized: aclose() leaves a borrowed client connected, so an aclose/re-enter cycle on a new loop fell through to the already-connected path and produced the driver's opaque error instead of the named one. - Store tools route through store.initialize() instead of a captured client: with injection disabled a tool call can be the store's first operation on a new loop. - add_messages banks retry tokens per chunk, not after the whole batch, so a batch failing on chunk 2 is not re-written from chunk 1 on retry. - _tool_prefix is 1:1. Sanitisation alone maps team/graph and team graph to the same prefix, and ToolRegistry overwrites duplicate @tool names silently; names that need rewriting now carry a digest of the original. - Quick Start configures extraction. extraction defaults off and MemoryManager disables add_memory by default, so the documented agent("Remember ...") persisted nothing; extraction=True alone is every fifth turn, hence the explicit InvocationTrigger. - The doc field-drift guard resolves a class by its import, not by name alone: strands.memory.ExtractionConfig and our config.settings one share a name and no fields. --- CHANGELOG.md | 4 + .../how-to/integrations/aws-strands.adoc | 29 ++- examples/strands-memory-store/main.py | 4 +- .../integrations/strands/_retrieval.py | 38 +++- .../integrations/strands/_store_tools.py | 29 ++- .../integrations/strands/memory_store.py | 32 +++- .../integrations/strands/session_manager.py | 8 +- tests/docs/test_code_snippets.py | 29 +++ tests/unit/integrations/strands_fakes.py | 5 + .../integrations/test_strands_memory_store.py | 176 +++++++++++++++++- .../test_strands_session_manager.py | 39 ++++ 11 files changed, 370 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec46ddad..922c0e57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 loop changes (Strands' synchronous `Agent(...)` runs every call on a fresh loop); a client passed as `client=` is never closed or reconnected, and a loop change raises a named error instead of an opaque driver `RuntimeError`. + With a `user_id` set, preference recall is scoped to that user in both the + store and `Neo4jSessionManager` injection (`search_preferences` applies no + user filter, so an unscoped call could surface another tenant's + preferences). Tool names are prefixed with the store's `name` so they coexist with `context_graph_tools`' identically-named tools rather than silently replacing them in the agent's registry, and `max_search_results` caps the diff --git a/docs/modules/ROOT/pages/how-to/integrations/aws-strands.adoc b/docs/modules/ROOT/pages/how-to/integrations/aws-strands.adoc index 1d68bbf0..9273bbb9 100644 --- a/docs/modules/ROOT/pages/how-to/integrations/aws-strands.adoc +++ b/docs/modules/ROOT/pages/how-to/integrations/aws-strands.adoc @@ -65,7 +65,7 @@ long-term recall injected straight into the agent loop: [source,python] ---- from strands import Agent -from strands.memory import MemoryManager +from strands.memory import ExtractionConfig, InvocationTrigger, MemoryManager from neo4j_agent_memory import MemorySettings from neo4j_agent_memory.integrations.strands import Neo4jMemoryStore, Neo4jMemoryStoreConfig @@ -74,7 +74,12 @@ settings = MemorySettings( neo4j={"uri": "neo4j+s://xxx.databases.neo4j.io", "password": "your-password"}, ) store = Neo4jMemoryStore( - Neo4jMemoryStoreConfig(name="graph", settings=settings, user_id="user-123") + Neo4jMemoryStoreConfig( + name="graph", + settings=settings, + user_id="user-123", + extraction=ExtractionConfig(trigger=InvocationTrigger()), + ) ) agent = Agent( @@ -85,6 +90,12 @@ agent = Agent( agent("Remember that I prefer Python over JavaScript") ---- +`extraction` is off by default and `MemoryManager` disables its `add_memory` +tool by default, so a store left at both defaults recalls but never writes. +`InvocationTrigger()` extracts on every turn; bare `extraction=True` means +every fifth. Writes go through `add_messages`, which the backend extracts +server-side — no extra model call. + Add `Neo4jSessionManager` for transcript persistence/restore (see <<_pairing_with_the_session_manager>>) and `context_graph_tools` for deep graph queries the store doesn't cover — all three are independent and @@ -103,8 +114,11 @@ tools = context_graph_tools( embedding_provider="bedrock", ) -# extract_entities=True: the session manager owns extraction here: the -# store's own extraction stays off (its default) — see the pairing rule. +# The session manager owns extraction here, so this store is recall-only: +# a second config with extraction left at its default — see the pairing rule. +store = Neo4jMemoryStore( + Neo4jMemoryStoreConfig(name="graph", settings=settings, user_id="user-123") +) manager = Neo4jSessionManager("support-42", settings=settings, extract_entities=True) agent = Agent( @@ -201,8 +215,11 @@ attribute, not a dict key. Pass exactly one of `client` (a pre-connected `(:User)-[:HAS_CONVERSATION]->(:Conversation)` edge plus a denormalized property; on NAMS, as a plain `userId` property on the conversation (no `:User` node, no edge) that the conversation listing filters on. Also gates - the `get_user_preferences` tool. It does *not* narrow `search()` — the - long-term search APIs take no user filter. + the `get_user_preferences` tool, and scopes preference recall in `search()` + to that user — `search_preferences` applies no user filter, so a scoped + store lists the user's active preferences instead of searching all of them. + Entity and fact recall stay unscoped: no user-scoped primitive exists for + them. | `include_entities` / `include_preferences` / `include_facts` | `True` diff --git a/examples/strands-memory-store/main.py b/examples/strands-memory-store/main.py index 6fbb50cb..e64e90d4 100644 --- a/examples/strands-memory-store/main.py +++ b/examples/strands-memory-store/main.py @@ -46,7 +46,9 @@ def build_settings() -> MemorySettings: async def main() -> None: async with MemoryClient(build_settings()) as client: - await client.long_term.add_preference("ui", "Prefers dark mode") + # user_identifier: a store with a user_id recalls only that user's + # preferences, so an unscoped one would not show up below. + await client.long_term.add_preference("ui", "Prefers dark mode", user_identifier="alice") await client.long_term.add_entity("Acme Corp", "ORGANIZATION") store = Neo4jMemoryStore( diff --git a/src/neo4j_agent_memory/integrations/strands/_retrieval.py b/src/neo4j_agent_memory/integrations/strands/_retrieval.py index 18311754..3249a90e 100644 --- a/src/neo4j_agent_memory/integrations/strands/_retrieval.py +++ b/src/neo4j_agent_memory/integrations/strands/_retrieval.py @@ -57,8 +57,35 @@ def _format_fact(fact: Fact) -> str: return f"[fact] {fact.subject} {fact.predicate} {fact.object}" +def _preference_search( + long_term: LongTermProtocol, user_id: str | None +) -> Callable[..., Awaitable[list[Any]]]: + """The preference lookup that is safe for this store's tenancy. + + ``search_preferences`` takes no user identifier and applies no ``:User`` + filter, so on a user-scoped construct it can return another tenant's + preferences. ``get_preferences_for`` is the only user-scoped primitive on + ``LongTermProtocol``; it is a listing rather than a search, so a scoped + lookup trades query relevance for tenancy correctness and returns the + user's active preferences up to ``limit``. + """ + if user_id is None: + return long_term.search_preferences + + async def _scoped(query: str, *, limit: int, threshold: float) -> list[Any]: + preferences = await long_term.get_preferences_for(user_identifier=user_id, active_only=True) + return preferences[:limit] + + return _scoped + + async def _retrieve_context( - long_term: LongTermProtocol, query: str, cfg: Neo4jRetrievalConfig, *, nams: bool + long_term: LongTermProtocol, + query: str, + cfg: Neo4jRetrievalConfig, + *, + nams: bool, + user_id: str | None = None, ) -> str: """Run the configured long-term searches concurrently and format the block. @@ -73,7 +100,11 @@ async def _retrieve_context( # NAMS has no preference/fact search endpoints — skip rather than warn every turn. wanted: list[tuple[bool, Callable[..., Awaitable[list[Any]]], Callable[..., str]]] = [ (cfg.include_entities, long_term.search_entities, _format_entity), - (cfg.include_preferences and not nams, long_term.search_preferences, _format_preference), + ( + cfg.include_preferences and not nams, + _preference_search(long_term, user_id), + _format_preference, + ), (cfg.include_facts and not nams, long_term.search_facts, _format_fact), ] searches = [s(query, limit=cfg.top_k, threshold=cfg.min_score) for on, s, _ in wanted if on] @@ -159,6 +190,7 @@ async def _retrieve_entries( include_preferences: bool, include_facts: bool, nams: bool, + user_id: str | None = None, ) -> list[_EntryRow]: """Sibling of ``_retrieve_context``: same fan-out, rows instead of a string. @@ -182,7 +214,7 @@ async def _retrieve_entries( ( "preference", include_preferences and not nams, - long_term.search_preferences, + _preference_search(long_term, user_id), _preference_row, ), ("fact", include_facts and not nams, long_term.search_facts, _fact_row), diff --git a/src/neo4j_agent_memory/integrations/strands/_store_tools.py b/src/neo4j_agent_memory/integrations/strands/_store_tools.py index ef7197c6..9392f2ba 100644 --- a/src/neo4j_agent_memory/integrations/strands/_store_tools.py +++ b/src/neo4j_agent_memory/integrations/strands/_store_tools.py @@ -13,6 +13,7 @@ from __future__ import annotations +import hashlib from typing import TYPE_CHECKING, Any, cast if TYPE_CHECKING: @@ -115,17 +116,29 @@ async def _user_preferences( def _tool_prefix(name: str) -> str: - """The store's name, reduced to something legal in a tool name. + """The store's name, reduced to something legal in a tool name, 1:1. Tool names are namespaced per store so they can coexist both with ``context_graph_tools``' identically-named tools and with a second store's (``dataclasses.replace(config, name="team")`` is the documented way to run personal / team / org stores side by side). + + Sanitization alone is many-to-one — ``"team/graph"``, ``"team graph"`` and + ``"Team_Graph"`` all reduce to ``team_graph`` — and colliding prefixes + would silently overwrite each other in ``ToolRegistry``, the exact failure + namespacing exists to prevent. So a name that does not survive + sanitization unchanged carries a short digest of the original: names that + are already legal keep clean prefixes, and any two distinct names stay + distinct. """ slug = "".join(char if char.isalnum() else "_" for char in name.lower()).strip("_") while "__" in slug: slug = slug.replace("__", "_") - return slug or "store" + base = slug or "store" + if base == name: + return base + digest = hashlib.blake2s(name.encode(), digest_size=3).hexdigest() + return f"{base}_{digest}" def build_store_tools(store: Neo4jMemoryStore) -> list[AgentTool]: @@ -140,7 +153,6 @@ def build_store_tools(store: Neo4jMemoryStore) -> list[AgentTool]: """ from strands import tool - client = store._client nams = store.is_nams prefix = _tool_prefix(store.name) @@ -155,7 +167,13 @@ async def _get_entity_graph(entity_name: str, depth: int = 2) -> dict[str, Any]: depth: How many hops to traverse. Hosted backends traverse one hop regardless. """ - return await _entity_graph(client, entity_name, depth=max(1, min(depth, 3)), nams=nams) + # Through the store, never straight to the captured client: with + # injection disabled a tool call can be the store's first operation on + # a new event loop, and only initialize() rebinds an owned client. + await store.initialize() + return await _entity_graph( + store._client, entity_name, depth=max(1, min(depth, 3)), nams=nams + ) # Annotated explicitly as AgentTool: @tool's overloads resolve this correctly # under mypy --strict and ty, but some IDEs' inference falls back to the @@ -185,7 +203,8 @@ async def _get_user_preferences(category: str | None = None, limit: int = 20) -> category: Optional category such as "food" or "ui". limit: Maximum preferences to return. """ - return await _user_preferences(client, user_id, category, limit=limit) + await store.initialize() + return await _user_preferences(store._client, user_id, category, limit=limit) get_user_preferences: AgentTool = tool(name=f"{prefix}_get_user_preferences")( _get_user_preferences diff --git a/src/neo4j_agent_memory/integrations/strands/memory_store.py b/src/neo4j_agent_memory/integrations/strands/memory_store.py index 74d8a3fe..61acd135 100644 --- a/src/neo4j_agent_memory/integrations/strands/memory_store.py +++ b/src/neo4j_agent_memory/integrations/strands/memory_store.py @@ -252,9 +252,13 @@ async def initialize(self) -> None: """ loop = asyncio.get_running_loop() - if self._initialized: - if self._connected_loop is loop: - return + # Keyed on the recorded loop, not on ``_initialized``: ``aclose()`` + # leaves a borrowed client connected on its original loop, so the guard + # has to survive an aclose/re-enter cycle. Were this ``if + # self._initialized``, that cycle would fall through to the + # already-connected branch, record the new loop and hand the caller the + # driver's opaque error instead of the named one below. + if self._connected_loop is not None and self._connected_loop is not loop: if not self._owns_client: raise RuntimeError( f"Neo4jMemoryStore '{self.name}': the MemoryClient passed as " @@ -276,6 +280,8 @@ async def initialize(self) -> None: error, ) await self._client.connect() + elif self._initialized: + return elif not self._client.is_connected: await self._client.connect() @@ -290,6 +296,11 @@ async def search(self, query: str, options: SearchOptions | None = None) -> list ``_retrieve_entries``; a total failure here propagates so ``MemoryManager.search`` can log a dead store rather than see an empty, misleadingly-successful result. + + With a ``user_id`` configured, preference recall is user-scoped (see + ``_preference_search``) — ``search_preferences`` has no user filter, so + an unscoped call here would inject another tenant's preferences into + this user's turn. """ await self.initialize() limit = (options or {}).get("max_search_results") @@ -307,6 +318,7 @@ async def search(self, query: str, options: SearchOptions | None = None) -> list include_preferences=self._include_preferences, include_facts=self._include_facts, nams=self.is_nams, + user_id=self.user_id, ) return [MemoryEntry(content=row.content, metadata=row.metadata) for row in rows] @@ -448,7 +460,13 @@ async def add_messages( extract_entities=True, user_identifier=self.user_id, ) - self._written.update(token for token in tokens if token is not None) + # Per chunk, not after the loop: Strands rolls its high-water mark + # back and retries the whole batch when this raises, so tokens + # banked only at the end would let an already-written chunk be + # written again by the retry. + self._written.update( + token for token in tokens[start : start + _BULK_CHUNK] if token is not None + ) return {"written": len(payload), "skipped": skipped} def get_tools(self) -> list[AgentTool]: @@ -486,7 +504,11 @@ async def aclose(self) -> None: self.name, ) self._initialized = False - self._connected_loop = None + if self._owns_client: + self._connected_loop = None + # Borrowed clients keep their recorded loop: this store did not close + # the client, so it is still bound to that loop and re-entering the + # store from another one must still raise the named error. async def __aenter__(self) -> Neo4jMemoryStore: await self.initialize() diff --git a/src/neo4j_agent_memory/integrations/strands/session_manager.py b/src/neo4j_agent_memory/integrations/strands/session_manager.py index d1a5000e..98de386e 100644 --- a/src/neo4j_agent_memory/integrations/strands/session_manager.py +++ b/src/neo4j_agent_memory/integrations/strands/session_manager.py @@ -315,7 +315,13 @@ def _inject_context(self, message: StrandsMessage) -> None: return # no text block, or already injected (event re-fired) try: block = self._bridge.run( - _retrieve_context(self._client.long_term, query, cfg, nams=self._is_nams) + _retrieve_context( + self._client.long_term, + query, + cfg, + nams=self._is_nams, + user_id=self._user_id, + ) ) except Exception as e: logger.warning( diff --git a/tests/docs/test_code_snippets.py b/tests/docs/test_code_snippets.py index c91208c7..5e0ab29c 100644 --- a/tests/docs/test_code_snippets.py +++ b/tests/docs/test_code_snippets.py @@ -447,6 +447,30 @@ class TestSettingsFieldDrift: "EnrichmentConfig": "neo4j_agent_memory.config.settings:EnrichmentConfig", } + @staticmethod + def _foreign_names(tree: ast.AST) -> set[str]: + """Names a snippet imported from somewhere other than this library. + + Class names are not unique across the ecosystem: ``ExtractionConfig`` + is both ``neo4j_agent_memory.config.settings.ExtractionConfig`` and + ``strands.memory.ExtractionConfig``, and the two share no fields. A + name-only lookup reports the wrong model's fields for the wrong class, + so a snippet that explicitly imports the third-party one is skipped. + Bare, un-imported usages are still checked — fragment snippets rely on + that. + """ + names: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + if (node.module or "").startswith("neo4j_agent_memory"): + continue + names.update(alias.asname or alias.name for alias in node.names) + elif isinstance(node, ast.Import): + for alias in node.names: + if not alias.name.startswith("neo4j_agent_memory"): + names.add((alias.asname or alias.name).split(".")[0]) + return names + @staticmethod def _resolve_model_fields(class_name: str) -> set[str] | None: spec = TestSettingsFieldDrift._MODELS_TO_CHECK.get(class_name) @@ -475,6 +499,8 @@ def test_no_unknown_kwargs_in_doc_constructions(self, python_snippets: list[Code # Syntax test owns this — skip here so we only report drift. continue + foreign = self._foreign_names(tree) + for node in ast.walk(tree): if not isinstance(node, ast.Call): continue @@ -487,6 +513,9 @@ def test_no_unknown_kwargs_in_doc_constructions(self, python_snippets: list[Code else: continue + if class_name in foreign: + continue + fields = self._resolve_model_fields(class_name) if fields is None: continue diff --git a/tests/unit/integrations/strands_fakes.py b/tests/unit/integrations/strands_fakes.py index 980ab6a0..dbe9f50b 100644 --- a/tests/unit/integrations/strands_fakes.py +++ b/tests/unit/integrations/strands_fakes.py @@ -208,6 +208,9 @@ def __init__(self) -> None: self.conversations: dict[str, Conversation] = {} self.add_message_calls: list[dict[str, Any]] = [] self.bulk_calls: list[dict[str, Any]] = [] + #: Raise from the Nth ``bulk_add_messages`` call onwards (1-based), to + #: exercise a batch that fails partway through its chunks. + self.fail_bulk_from: int | None = None self.deleted_message_ids: list[str] = [] self.fail_next_add = False self.list_conversations_calls: list[dict[str, Any]] = [] @@ -274,6 +277,8 @@ async def bulk_add_messages( }, } ) + if self.fail_bulk_from is not None and len(self.bulk_calls) >= self.fail_bulk_from: + raise RuntimeError("bulk write failed") if session_id not in self.conversations: await self.create_conversation(session_id=session_id, user_identifier=user_identifier) stored = [Message(role=MessageRole(m["role"]), content=m["content"]) for m in messages] diff --git a/tests/unit/integrations/test_strands_memory_store.py b/tests/unit/integrations/test_strands_memory_store.py index 4671b1de..8ffea0b5 100644 --- a/tests/unit/integrations/test_strands_memory_store.py +++ b/tests/unit/integrations/test_strands_memory_store.py @@ -205,6 +205,56 @@ def test_a_borrowed_client_raises_a_named_error_on_a_new_loop(self) -> None: assert client.close_calls == 0 assert client.connect_calls == 1 + def test_a_borrowed_client_still_raises_after_aclose(self) -> None: + """``aclose()`` leaves a borrowed client connected, so the guard must hold. + + Clearing the recorded loop here would send the next ``initialize()`` + down the already-connected path, which records the new loop and lets + the driver raise its opaque error instead of this named one. + """ + from strands._async import run_async + + client = FakeMemoryClient() + store = _store(name="graph", client=client) + + run_async(store.initialize) + run_async(store.aclose) + + with pytest.raises(RuntimeError, match="different event loop"): + run_async(store.initialize) + + assert client.close_calls == 0 + + def test_a_tool_call_goes_through_the_store_lifecycle(self) -> None: + """Tools must not bypass ``initialize()``. + + With injection disabled a tool call can be the store's first + operation on a new loop; a tool holding the client directly would + drive a stale transport instead of taking the rebind path. + """ + from strands._async import run_async + + client = FakeMemoryClient() + store = _store(name="graph", client=client) + tool = next(t for t in store.get_tools() if t.tool_name.endswith("get_entity_graph")) + + run_async(store.initialize) + + use = {"toolUseId": "t1", "name": tool.tool_name, "input": {"entity_name": "Acme"}} + events: list[Any] = [] + + async def call() -> None: + async for event in tool.stream(use, {}): + events.append(event) + + run_async(call) + + # Strands turns a tool exception into an error result rather than + # propagating it, so assert on the result the agent would see. + result = events[-1]["tool_result"] + assert result["status"] == "error" + assert "different event loop" in result["content"][0]["text"] + def test_the_same_loop_stays_idempotent(self) -> None: """Only a *changed* loop triggers the rebind path.""" from strands._async import run_async @@ -808,6 +858,109 @@ async def test_bulk_kwargs_bind_against_the_real_bolt_signature(self) -> None: real_signature.bind(None, call["session_id"], call["messages"], **call["kwargs"]) +class TestPreferenceTenancy: + """A user-scoped store must not recall another tenant's preferences. + + ``search_preferences`` takes no user identifier and applies no ``:User`` + filter, so using it on a scoped store injects whatever the vector index + ranks highest — including preferences belonging to somebody else. + ``get_preferences_for`` is the only user-scoped primitive available. + """ + + @pytest.mark.asyncio + async def test_a_scoped_store_recalls_only_its_own_users_preferences(self) -> None: + client = FakeMemoryClient() + await client.long_term.add_preference("ui", "alice: dark mode", user_identifier="alice") + await client.long_term.add_preference("ui", "bob: light mode", user_identifier="bob") + + store = _store( + name="graph", + client=client, + user_id="alice", + include_entities=False, + include_facts=False, + ) + await store.initialize() + entries = await store.search("theme") + + assert [e.content for e in entries] == ["[preference] ui: alice: dark mode"] + assert client.long_term.preferences_for_calls == [ + {"user_identifier": "alice", "active_only": True} + ] + + @pytest.mark.asyncio + async def test_an_unscoped_preference_is_invisible_to_a_scoped_store(self) -> None: + """The leak, stated as a test: an unscoped write has no ``:User`` edge.""" + from neo4j_agent_memory.memory.long_term import Preference + + client = FakeMemoryClient() + # What search_preferences would have returned regardless of tenancy. + client.long_term.preferences = [Preference(category="ui", preference="somebody's theme")] + + store = _store( + name="graph", + client=client, + user_id="alice", + include_entities=False, + include_facts=False, + ) + await store.initialize() + + assert await store.search("theme") == [] + + @pytest.mark.asyncio + async def test_an_unscoped_store_still_searches(self) -> None: + """Without a ``user_id`` there is no tenant to scope to, so search stands.""" + from neo4j_agent_memory.memory.long_term import Preference + + client = FakeMemoryClient() + client.long_term.preferences = [Preference(category="ui", preference="dark mode")] + + store = _store(name="graph", client=client, include_entities=False, include_facts=False) + await store.initialize() + entries = await store.search("theme") + + assert [e.content for e in entries] == ["[preference] ui: dark mode"] + assert client.long_term.preferences_for_calls == [] + + +class TestPartialBatchRetry: + """A batch that fails partway must not re-write the chunks that landed. + + Strands rolls its high-water mark back and retries the whole batch when + ``add_messages`` raises, so the in-process ``_written`` set is what stops + an already-written chunk being written twice. + """ + + @pytest.mark.asyncio + async def test_a_chunk_that_landed_is_not_written_again_by_the_retry(self) -> None: + from strands.memory import AddMessagesContext + + client = FakeMemoryClient() + store = _store(name="graph", client=client) + await store.initialize() + + # 150 messages -> two chunks (_BULK_CHUNK == 100); the second fails. + messages = [{"role": "user", "content": [{"text": f"m{i}"}]} for i in range(150)] + context = AddMessagesContext(sequence_numbers=list(range(150))) + client.short_term.fail_bulk_from = 2 + + with pytest.raises(RuntimeError, match="bulk write failed"): + await store.add_messages(messages, context) + + assert len(client.short_term.bulk_calls) == 2 + first_chunk_texts = [m["content"] for m in client.short_term.bulk_calls[0]["messages"]] + assert first_chunk_texts == [f"m{i}" for i in range(100)] + + # The retry: same batch, same sequence numbers, now succeeding. + client.short_term.fail_bulk_from = None + result = await store.add_messages(messages, context) + + assert result == {"written": 50, "skipped": 100} + retried = [m["content"] for m in client.short_term.bulk_calls[2]["messages"]] + assert retried == [f"m{i}" for i in range(100, 150)] + + class TestWriteSinks: def test_declares_both_write_sinks(self) -> None: """Both sinks on one class: server-side extraction, `add` still available.""" @@ -1031,9 +1184,28 @@ def test_two_stores_on_one_manager_get_distinct_names(self) -> None: def test_a_name_needing_sanitising_still_yields_a_legal_prefix(self) -> None: store = _store(name="Team / Graph!", client=FakeMemoryClient()) - names = {t.tool_name for t in store.get_tools()} + (name,) = {t.tool_name for t in store.get_tools()} + + assert name.startswith("team_graph_") + assert name.endswith("_get_entity_graph") + assert name.replace("_", "").isalnum() + + def test_names_that_sanitise_alike_stay_distinct(self) -> None: + """Sanitisation is many-to-one; the resulting prefixes must not be. + + ``ToolRegistry`` overwrites a duplicate ``@tool`` name silently, so + two stores whose names differ only in punctuation would clobber each + other's tools — the failure namespacing exists to prevent. + """ + client = FakeMemoryClient() + alike = ["team/graph", "team graph", "Team_Graph", "team_graph"] + + prefixes = [ + {t.tool_name for t in _store(name=name, client=client).get_tools()} for name in alike + ] - assert names == {"team_graph_get_entity_graph"} + flat = [name for names in prefixes for name in names] + assert len(flat) == len(set(flat)) class TestPreferenceRoundTrip: diff --git a/tests/unit/integrations/test_strands_session_manager.py b/tests/unit/integrations/test_strands_session_manager.py index 6ff5c495..6a1950cd 100644 --- a/tests/unit/integrations/test_strands_session_manager.py +++ b/tests/unit/integrations/test_strands_session_manager.py @@ -823,6 +823,45 @@ def test_nams_list_conversations_passes_user_identifier_and_limit(self) -> None: manager.close() +class TestRetrievalInjectionTenancy: + """Injection must respect the manager's ``user_id``. + + ``search_preferences`` has no ``:User`` filter, so a user-scoped session + that searches would fold another tenant's preferences into this user's + turn. Same defect and same fix as ``Neo4jMemoryStore.search``. + """ + + def test_a_scoped_manager_injects_only_its_own_users_preferences(self) -> None: + from neo4j_agent_memory.integrations.strands.session_manager import ( + Neo4jRetrievalConfig, + ) + + manager, client = _make_manager( + user_id="alice", + retrieval_config=Neo4jRetrievalConfig(include_entities=False, include_facts=False), + ) + # What an unscoped search would have surfaced. + client.long_term.preferences = [ + SimpleNamespace(category="style", preference="somebody else's style") + ] + client.long_term.preferences_by_user["alice"] = [ + SimpleNamespace(category="style", preference="concise answers") + ] + try: + manager.initialize(_fake_agent()) + message = {"role": "user", "content": [{"text": "hi"}]} + manager._inject_context(message) + text = message["content"][0]["text"] + + assert "[preference] style: concise answers" in text + assert "somebody else's style" not in text + assert client.long_term.preferences_for_calls == [ + {"user_identifier": "alice", "active_only": True} + ] + finally: + manager.close() + + class TestRetrievalInjectionExtended: """Fix 4: idempotency guard fires BEFORE the retrieval call."""