Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
parameter was renamed `entity` → `entity_id` (all in-tree callers pass it
positionally); code using the `entity=` keyword must switch to `entity_id=`.

### Fixed

- **NAMS `add_entity` no longer raises `ValidationError` when the server
auto-merges the create onto an existing entity.** NAMS resolves-before-create:
a sufficiently similar name responds `{id, resolution: "merged", merged_into,
confidence}` with no `name`/`type`, which previously failed Pydantic `Entity`
parsing. The client now follows up with `GET /entities/{id}` and returns the
canonical merged-into entity. Fallback is limited to 404 or empty/incomplete
canonical responses with a valid merge ID; authentication, rate-limit,
transport, and malformed-data errors propagate. HTTP 404 now raises the
exported `NotFoundError`, a `MemoryError` subclass. Merge details are preserved
in `metadata.nams_resolution`, including a `fallback` flag and the separate
`merge_confidence` score; fallback entity confidence uses the model default.
Null confidence and collection fields use meaningful defaults, while invalid
server IDs and explicit null/invalid creation timestamps fail validation.

> **Docs note:** when this ships, flip the "REST-only / no SDK method" notes in
> `reference/rest-api.adoc`, `reference/ontology-api.adoc`, and
> `reference/authentication.adoc`, and the Python↔TS parity note in
Expand Down
8 changes: 7 additions & 1 deletion docs/modules/ROOT/pages/how-to/migrate-to-nams.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ These methods exist on `NamsLongTermMemory` / `NamsShortTermMemory` but not on t
=== Deduplication

* **Bolt**: client-side deduplication runs on every `add_entity` (configurable via `DeduplicationConfig`). `add_entity` returns `(Entity, DeduplicationResult)` — the tuple carries info about whether a merge happened.
* **NAMS**: dedup is server-side and opaque to the client. `add_entity` returns just `Entity`. Existing code that unpacks the tuple (`entity, dedup = await client.long_term.add_entity(...)`) **will break** on NAMS — the result isn't iterable as a 2-tuple. Migrate to:
* **NAMS**: dedup is server-side. `add_entity` returns just `Entity`; auto-merge details are available in `entity.metadata["nams_resolution"]`. Existing code that unpacks the tuple (`entity, dedup = await client.long_term.add_entity(...)`) **will break** on NAMS — the result isn't iterable as a 2-tuple. Migrate to:
+
```python
result = await client.long_term.add_entity(...)
Expand All @@ -231,6 +231,12 @@ These methods exist on `NamsLongTermMemory` / `NamsShortTermMemory` but not on t
entity = result # NAMS
```

For auto-merges, `nams_resolution` contains `merged_into`, the separate
`merge_confidence` score when present, and a `fallback` flag. A fallback uses
request/default-derived fields when the canonical read returns 404 or an
incomplete response. See xref:reference/rest-api.adoc#auto-merged-entity-creates[Auto-merged entity creates]
for the return and error behavior.

=== Embeddings and vector indexes

* **Bolt**: you configure an embedder (OpenAI, sentence-transformers, etc.) on `MemorySettings.embedding`; the client embeds text before storage; vector indexes are created at `connect()` sized to the embedder's dimensions.
Expand Down
42 changes: 41 additions & 1 deletion docs/modules/ROOT/pages/reference/rest-api.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,43 @@ excluding any `loaded_ids` you already have — it backs incremental
graph-visualization ("expand this node"). Exposed as `long_term.expand_graph` /
`longTerm.expandGraph`.

[#auto-merged-entity-creates]
=== Auto-merged entity creates

When `POST /entities` resolves a name to an existing entity, it returns a
`resolution: "merged"` envelope instead of a complete entity. Python
`add_entity` and TypeScript `addEntity` fetch the canonical entity and return it.
Both expose the create operation's resolution in `entity.metadata` under the
reserved `nams_resolution` key, preserving other entity metadata:

[source,json]
----
{
"nams_resolution": {
"resolution": "merged",
"merged_into": "00000000-0000-0000-0000-000000000001",
"merge_confidence": 0.93,
"fallback": false
}
}
----

`merge_confidence` is the service's name-match score and is omitted when absent
or null. It does not replace the canonical entity's own `confidence`.

If the canonical GET returns 404, an empty body, or an incomplete response
without a usable name, the SDK can return a fallback using the server-provided
merge ID and requested name/type. `fallback: true` identifies this result;
its creation timestamp is generated by the client, not read from the server.
Python uses its entity confidence default of `1.0`; TypeScript leaves entity
confidence undefined. These fallback values are not estimates of stored
canonical values. Missing merge IDs and malformed populated fields are
validation errors. Authentication, rate-limit, network, and server errors
propagate to the caller instead of returning a fallback.

Ordinary creates and `review_pending` responses return their entity directly
without a follow-up GET.

== Reasoning

[cols="2,3,1,1,1,1,1"]
Expand Down Expand Up @@ -187,7 +224,7 @@ Error responses use standard HTTP status codes with a JSON body of the shape

| `400` | Invalid request, write Cypher attempted, validation failure | `ValidationError` (carries `details`)
| `401`, `403` | Missing/invalid token, or workspace not accessible | `AuthenticationError`
| `404` | Resource not found | `MemoryError` (base class)
| `404` | Resource not found | `NotFoundError` (subclasses `MemoryError`)
| `405`, `501` | Operation not available on this backend | `NotSupportedError`
| `429` | Rate limited | `RateLimitError` (carries `retry_after`; honors `Retry-After`)
| `5xx` | Service error | `TransportError` (after retries exhausted)
Expand All @@ -198,6 +235,9 @@ All SDK exceptions subclass `MemoryError`. The SDK retries `429`, `5xx`, and
network errors with exponential backoff, honoring any `Retry-After` header. See xref:reference/nams-limits.adoc[NAMS Limits & Behavior]
for rate-limit and quota specifics.

The TypeScript REST transport reports 404 as `TransportError` with
`statusCode === 404`; its exported `NotFoundError` is not used by that transport.

== Pagination

List endpoints accept `limit` and `offset` query parameters for offset-based
Expand Down
2 changes: 2 additions & 0 deletions src/neo4j_agent_memory/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
ExtractionError,
MemoryError,
NotConnectedError,
NotFoundError,
NotSupportedError,
RateLimitError,
ResolutionError,
Expand Down Expand Up @@ -318,6 +319,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
# NAMS exceptions (v0.4)
"TransportError",
"AuthenticationError",
"NotFoundError",
"NotSupportedError",
"RateLimitError",
"ValidationError",
Expand Down
2 changes: 2 additions & 0 deletions src/neo4j_agent_memory/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
EmbeddingError,
ExtractionError,
MemoryError,
NotFoundError,
ResolutionError,
SchemaError,
)
Expand All @@ -17,6 +18,7 @@
__all__ = [
# Exceptions
"MemoryError",
"NotFoundError",
"ConnectionError",
"SchemaError",
"ExtractionError",
Expand Down
6 changes: 6 additions & 0 deletions src/neo4j_agent_memory/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ class AuthenticationError(MemoryError):
pass


class NotFoundError(MemoryError):
"""Raised when the NAMS backend cannot find a resource (HTTP 404)."""

pass


class RateLimitError(MemoryError):
"""Raised when the NAMS backend rate-limits the client.

Expand Down
112 changes: 101 additions & 11 deletions src/neo4j_agent_memory/nams/long_term.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,13 @@
import asyncio
import time
from collections.abc import Callable
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
from uuid import UUID

from neo4j_agent_memory.core.exceptions import NotSupportedError
from pydantic import TypeAdapter

from neo4j_agent_memory.core.exceptions import NotFoundError, NotSupportedError, ValidationError
from neo4j_agent_memory.memory.long_term import (
Entity,
Fact,
Expand Down Expand Up @@ -167,18 +170,17 @@ def _normalize_entity(payload: dict[str, Any] | None) -> dict[str, Any]:
doesn't provide — we default them so Pydantic parsing succeeds.
NAMS types come back lowercase; uppercase them so package-side
consumers see the same type values they sent.
"""
from datetime import datetime, timezone

Known nullable confidence/collection fields use model defaults. Identity
and explicit timestamps remain subject to validation; a missing server ID
must never invoke the model's UUID factory. Missing creation timestamps
retain the existing model-default behavior for compatibility.
"""
data = snakeize_keys(payload) if isinstance(payload, dict) else {}
if "created_at" not in data:
data["created_at"] = datetime.now(timezone.utc).isoformat()
if "metadata" not in data:
data["metadata"] = {}
if "aliases" not in data:
data["aliases"] = []
if "attributes" not in data:
data["attributes"] = {}
for field in ("confidence", "metadata", "aliases", "attributes"):
if data.get(field) is None:
data.pop(field, None)
data.setdefault("id", None)
if isinstance(data.get("type"), str):
data["type"] = data["type"].upper()
return data
Expand Down Expand Up @@ -215,6 +217,15 @@ async def add_entity(
``{name, type, description?}`` per spec. Bolt-only kwargs
(``subtype``, ``aliases``, ``attributes``, ``confidence``,
``deduplicate``, ``geocode``, ``enrich``, etc.) are silently dropped.

NAMS resolves-before-create: when the name is close enough to an
existing entity, the server merges onto it and responds
``{id, resolution: "merged", merged_into, confidence}`` — no
``name``/``type``. We follow up with ``GET /entities/{id}`` and
return the canonical merged-into entity. A 404 or incomplete read
falls back to the request's name/type with an explicit local UTC
timestamp. Other read failures propagate. ``metadata.nams_resolution``
preserves the merge outcome and score and identifies fallback records.
"""
et = entity_type or kwargs.get("type") or kwargs.get("label")
if et is None:
Expand All @@ -227,8 +238,87 @@ async def add_entity(
}
)
payload = await self._transport.request(_SPEC_ADD_ENTITY, json=body)
if isinstance(payload, dict) and payload.get("resolution") == "merged":
payload = await self._resolve_merged_entity(payload, name=name, nams_type=body["type"])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolution / merged_into / merge confidence are discarded — caller asks for "Alice Smith", gets "Alice" under a different id, no signal.

_normalize_entity already defaults metadata to {}; {"resolution": "merged", "merged_into": ..., "merge_confidence": ...} there is non-breaking.

return payload_to_model(_normalize_entity(payload), Entity)

async def _resolve_merged_entity(
self,
payload: dict[str, Any],
*,
name: str,
nams_type: str,
) -> dict[str, Any]:
"""Turn a ``resolution: "merged"`` create response into entity fields.

A missing canonical record (404, empty body, or missing name) may use
request fields, but malformed populated fields and other read errors
remain visible. The merge score describes similarity, not the returned
entity's confidence, and is preserved separately in metadata.
"""
raw_merged_id = None
for key in ("merged_into", "mergedInto", "id"):
value = payload.get(key)
if value is None or (isinstance(value, str) and not value.strip()):
continue
raw_merged_id = value
break
merged_id = TypeAdapter(UUID).validate_python(raw_merged_id)
envelope_metadata = snakeize_keys(
TypeAdapter(dict[str, Any]).validate_python(
{} if payload.get("metadata") is None else payload["metadata"]
)
)
try:
detail = await self._transport.request(
_SPEC_GET_ENTITY,
path_params={"entity_id": str(merged_id)},
)
except NotFoundError:
detail = None

data = (
{}
if detail is None
else snakeize_keys(TypeAdapter(dict[str, Any]).validate_python(detail))
)
canonical_name = data.get("name")
fallback = canonical_name is None or (
isinstance(canonical_name, str) and not canonical_name.strip()
)
if fallback:
# Validate fields that did arrive before building a fallback. Only
# absent identity/type and missing names can use request context.
candidate = {"id": str(merged_id), "type": nams_type, **data, "name": name}
else:
candidate = data
canonical = payload_to_model(_normalize_entity(candidate), Entity)
if canonical.id != merged_id:
raise ValidationError("NAMS canonical entity ID does not match the merge target")
if not canonical.type.strip():
raise ValidationError("NAMS canonical entity type must not be blank")

if fallback:
data = {
"id": str(merged_id),
"name": name,
"type": nams_type,
"created_at": datetime.now(timezone.utc).isoformat(),
}
resolution: dict[str, Any] = {
"resolution": "merged",
"merged_into": str(merged_id),
"fallback": fallback,
}
if payload.get("confidence") is not None:
resolution["merge_confidence"] = payload["confidence"]
data["metadata"] = {
**envelope_metadata,
**canonical.metadata,
"nams_resolution": resolution,
}
return data

async def add_preference(self, category: str, preference: str, **kwargs: Any) -> Preference:
raise NotSupportedError(
backend="nams",
Expand Down
4 changes: 2 additions & 2 deletions src/neo4j_agent_memory/nams/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from neo4j_agent_memory.config.settings import NamsConfig
from neo4j_agent_memory.core.exceptions import (
AuthenticationError,
MemoryError,
NotFoundError,
NotSupportedError,
RateLimitError,
TransportError,
Expand Down Expand Up @@ -356,7 +356,7 @@ def _raise_for_status(
if status in (401, 403):
raise AuthenticationError(message)
if status == 404:
raise MemoryError(message)
raise NotFoundError(message)
if status in (405, 501):
raise NotSupportedError(
backend="nams",
Expand Down
10 changes: 5 additions & 5 deletions tests/integration/nams/test_framework_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,11 @@ async def test_crewai_smoke(nams_client: MemoryClient, nams_session: str) -> Non
except (ImportError, AttributeError):
pytest.skip("crewai integration unavailable")

# CrewAI's adapter is crew-scoped (no session_id) and uses an internal
# crew id rather than a pre-created NAMS conversation, so we assert it
# constructs against a NAMS-backed client and round-trip the write path
# through the shared client.
_ = Neo4jCrewMemory(memory_client=nams_client)
# CrewAI's adapter is crew-scoped: crew_id (not session_id) names the
# conversation it writes under. We assert it constructs against a
# NAMS-backed client and round-trip the write path through the shared
# client.
_ = Neo4jCrewMemory(memory_client=nams_client, crew_id=f"{nams_session}-crew")
marker = _marker()

async def store() -> None:
Expand Down
8 changes: 7 additions & 1 deletion tests/integration/nams/test_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,13 @@ async def test_smoke_full_flow(nams_client: MemoryClient, nams_session: str) ->
entity_name = f"SmokeTest-{uuid.uuid4().hex[:8]}"
entity = await nams_client.long_term.add_entity(entity_name, "PERSON")
entity = entity[0] if isinstance(entity, tuple) else entity
assert entity.name == entity_name
assert entity.name.strip()
if entity.name != entity_name:
# Similar smoke-test names can merge across runs in the shared sandbox.
resolution = entity.metadata.get("nams_resolution", {})
assert resolution.get("resolution") == "merged"
assert resolution.get("merged_into") == str(entity.id)
assert resolution.get("fallback") is False

# Reasoning: tied to the session.
trace = await nams_client.reasoning.start_trace(nams_session, "smoke task")
Expand Down
8 changes: 5 additions & 3 deletions tests/integration/nams/test_tck_gold.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,11 @@ async def test_entity_visible_across_sessions(
e = await nams_client.long_term.add_entity(entity_name, "PERSON")
e = e[0] if isinstance(e, tuple) else e

# Query from session B context. NAMS search is async-indexed; poll.
# Query the returned canonical name: the create may have merged onto a
# prior run's entity. NAMS search is async-indexed; poll.
found = None
for _ in range(10): # ~5s
found = await nams_client.long_term.get_entity_by_name(entity_name)
found = await nams_client.long_term.get_entity_by_name(e.name)
if found is not None:
break
await asyncio.sleep(0.5)
Expand All @@ -191,4 +192,5 @@ async def test_entity_visible_across_sessions(
"Cross-session visibility is verified by the write succeeding "
f"with entity id {e.id} from session_a={session_a}."
)
assert found.name == entity_name
assert found.name == e.name
assert found.id == e.id
Loading
Loading